From dd84dff055978b77e6588ea40a7edc01d5d0d956 Mon Sep 17 00:00:00 2001 From: JB Date: Sat, 1 Aug 2026 21:42:09 -0400 Subject: [PATCH] feat: add community publishing and news syndication --- .gitignore | 6 +- .husky/pre-commit | 1 + .../2026-07-26T15-49-23Z__src-routes.md | 62 +- .impeccable/design.json | 242 +- .prettierignore | 5 + .prettierrc | 17 + .vscode/extensions.json | 2 +- PRODUCT.md | 25 +- README.md | 30 +- database/schema.sql | 232 +- deploy/compose.production.yml | 5 +- deploy/compose.staging.yml | 5 +- docs/CONTENT_MODERATION.md | 71 +- docs/DEPLOYMENT.md | 153 +- docs/DESIGN_SYSTEM.md | 1800 +- docs/architecture.md | 496 +- package-lock.json | 8258 ++++--- package.json | 172 +- scripts/article-transfer.mjs | 425 + scripts/data/resurgence-attributes.json | 340 +- scripts/db-backup.mjs | 42 +- scripts/db-migrate.mjs | 3316 ++- scripts/db-restore.mjs | 71 +- scripts/default-users.mjs | 157 +- scripts/env-files.mjs | 21 +- scripts/environment-bootstrap-export.mjs | 103 +- scripts/environment-bootstrap-import.mjs | 196 +- scripts/import-resurgence-attributes.mjs | 263 +- scripts/isac-data-export.mjs | 92 +- scripts/isac-data-import.mjs | 132 +- scripts/isac-data-tables.mjs | 147 +- scripts/managed-images-export.mjs | 57 +- scripts/managed-images-import.mjs | 271 +- scripts/notification-digest.mjs | 92 +- scripts/oversight-notification-dispatch.mjs | 137 +- scripts/render-brand-assets.mjs | 75 +- scripts/scheduler-client.mjs | 26 +- scripts/seed-initial-users.mjs | 4 +- scripts/seed-test-notifications.mjs | 61 +- scripts/stage-bootstrap-export.mjs | 101 +- scripts/stage-bootstrap-import.mjs | 181 +- scripts/sync-staging-credentials.mjs | 58 +- scripts/system-notification-dispatch.mjs | 140 +- scripts/trim-static-icons.mjs | 58 +- src/app.css | 20242 ++++++++++++++-- src/app.d.ts | 20 +- src/app.html | 18 +- src/error.html | 146 +- src/hooks.server.ts | 224 +- src/lib/agents.test.ts | 83 +- src/lib/agents.ts | 46 +- src/lib/ai/calculation-authoring.ts | 156 +- src/lib/ai/mechanic-authoring.ts | 197 +- src/lib/ai/mechanic-developer-handoff.test.ts | 120 +- src/lib/ai/mechanic-developer-handoff.ts | 69 +- src/lib/articles/document.test.ts | 112 + src/lib/articles/document.ts | 280 + src/lib/articles/editor-extensions.ts | 71 + src/lib/avatar.ts | 102 +- src/lib/builder/loadout-combat.test.ts | 1294 +- src/lib/builder/loadout-combat.ts | 2878 ++- src/lib/builder/loadout.test.ts | 116 +- src/lib/builder/loadout.ts | 689 +- .../calculations/calculation-script.test.ts | 91 +- src/lib/calculations/calculation-script.ts | 224 +- src/lib/calculations/combat.test.ts | 347 +- src/lib/calculations/combat.ts | 915 +- src/lib/calculations/conditional.test.ts | 908 +- src/lib/calculations/conditional.ts | 1820 +- src/lib/calculations/mechanic-editor.test.ts | 1322 +- src/lib/calculations/mechanic-editor.ts | 1632 +- .../calculations/mechanic-persistence.test.ts | 1371 +- src/lib/calculations/mechanic-persistence.ts | 1677 +- src/lib/calculations/mechanic-recipes.test.ts | 392 +- src/lib/calculations/mechanic-recipes.ts | 763 +- src/lib/calculations/mechanic.test.ts | 4416 ++-- src/lib/calculations/mechanic.ts | 4830 ++-- src/lib/calculations/skill.test.ts | 559 +- src/lib/calculations/skill.ts | 1017 +- .../calculations/talent-description.test.ts | 213 +- src/lib/calculations/talent-description.ts | 64 +- src/lib/components/ArticleCard.svelte | 17 + src/lib/components/ArticleComposer.svelte | 143 + src/lib/components/ArticleContent.svelte | 66 + src/lib/components/ArticleDiscussion.svelte | 30 + src/lib/components/ArticleEditor.svelte | 188 + src/lib/components/ArticlePage.svelte | 48 + src/lib/components/BuildEmbed.svelte | 40 + src/lib/components/ContentReport.svelte | 5 +- .../DatabaseDiscussionDialog.svelte | 47 + src/lib/components/DatabaseItemCard.svelte | 38 +- src/lib/components/GameDataDiscussion.svelte | 110 + src/lib/components/OverflowMenu.svelte | 4 +- src/lib/database/categories.ts | 68 +- src/lib/database/items.ts | 72 +- src/lib/game-data-targets.test.ts | 32 + src/lib/game-data-targets.ts | 41 + src/lib/game-icons.ts | 61 +- src/lib/markdown.ts | 218 +- src/lib/server/account-control-data.ts | 137 +- src/lib/server/agent-profiles.test.ts | 44 +- src/lib/server/agent-profiles.ts | 156 +- .../server/ai/calculation-authoring-prompt.ts | 53 +- .../ai/calculation-authoring-schema.test.ts | 164 +- .../server/ai/calculation-authoring-schema.ts | 775 +- .../ai/calculation-authoring-service.ts | 294 +- .../server/ai/mechanic-authoring-context.ts | 82 +- .../ai/mechanic-authoring-prompt.test.ts | 613 +- .../server/ai/mechanic-authoring-prompt.ts | 749 +- .../ai/mechanic-authoring-rate-limit.test.ts | 40 +- .../ai/mechanic-authoring-rate-limit.ts | 72 +- .../ai/mechanic-authoring-schema.test.ts | 158 +- .../server/ai/mechanic-authoring-schema.ts | 690 +- .../server/ai/mechanic-authoring-service.ts | 375 +- src/lib/server/ai/mechanic-gap-report.test.ts | 264 +- src/lib/server/ai/mechanic-gap-report.ts | 458 +- src/lib/server/anonymous-visitors.ts | 96 + src/lib/server/article-discussions.ts | 204 + src/lib/server/article-form.ts | 48 + src/lib/server/article-page.ts | 365 + src/lib/server/articles.ts | 1164 + src/lib/server/audit.ts | 94 +- src/lib/server/auth/account-deletion.ts | 149 +- src/lib/server/auth/authorization.ts | 73 +- src/lib/server/auth/bootstrap.ts | 9 +- src/lib/server/auth/magic-link.ts | 55 +- src/lib/server/auth/mail.ts | 99 +- src/lib/server/auth/methods.ts | 48 +- src/lib/server/auth/passkeys.ts | 73 +- src/lib/server/auth/password.ts | 19 +- src/lib/server/auth/session.ts | 312 +- src/lib/server/automated-messages.ts | 134 +- src/lib/server/avatars.ts | 293 +- src/lib/server/comment-removal.test.ts | 12 + src/lib/server/comment-removal.ts | 51 + src/lib/server/community-moderation.test.ts | 310 +- src/lib/server/community-moderation.ts | 1331 +- src/lib/server/conditional-effects.test.ts | 722 +- src/lib/server/conditional-effects.ts | 883 +- src/lib/server/contributor-mode.ts | 24 +- src/lib/server/data-moderation.ts | 431 +- src/lib/server/database.ts | 11 +- src/lib/server/game-data-discussions.ts | 67 + src/lib/server/game-data-icons.ts | 635 +- src/lib/server/managed-images.test.ts | 288 +- src/lib/server/managed-images.ts | 513 +- src/lib/server/markdown.test.ts | 38 +- src/lib/server/notification-sort.test.ts | 42 +- src/lib/server/notification-sort.ts | 27 +- src/lib/server/notifications.ts | 119 +- src/lib/server/oversight-notifications.ts | 187 +- src/lib/server/pagination.test.ts | 74 +- src/lib/server/pagination.ts | 98 +- src/lib/server/profile-comment-follows.ts | 122 +- src/lib/server/profile-comment-thread.test.ts | 169 +- src/lib/server/profile-comment-thread.ts | 170 +- src/lib/server/profile-settings.ts | 123 +- src/lib/server/remove-background.ts | 109 +- src/lib/server/remove-bg-monitor.test.ts | 164 +- src/lib/server/remove-bg-monitor.ts | 423 +- src/lib/server/request-origin.test.ts | 81 +- src/lib/server/request-origin.ts | 20 +- src/lib/server/scheduled-notifications.ts | 372 +- src/lib/server/scheduled-tasks.ts | 32 + src/lib/server/shared-build-combat.ts | 588 +- src/lib/server/site-access.test.ts | 342 +- src/lib/server/site-access.ts | 131 +- src/lib/server/specialization-readiness.ts | 143 +- src/lib/server/ubisoft-news-client.test.ts | 125 + src/lib/server/ubisoft-news-client.ts | 270 + src/lib/server/ubisoft-news-import.test.ts | 302 + src/lib/server/ubisoft-news-import.ts | 453 + src/lib/toast.ts | 185 +- src/lib/weapons/category-bonuses.test.ts | 69 +- src/lib/weapons/category-bonuses.ts | 23 +- src/lib/weapons/mod-slots.test.ts | 147 +- src/lib/weapons/mod-slots.ts | 194 +- src/routes/+layout.server.ts | 14 +- src/routes/+page.svelte | 2 +- src/routes/account/+page.server.ts | 758 +- src/routes/account/articles/+page.server.ts | 22 + src/routes/account/articles/+page.svelte | 4 + .../account/contact/[id]/+page.server.ts | 21 +- .../account/notifications/+page.server.ts | 308 +- src/routes/agents/[id]/+page.server.ts | 1387 +- src/routes/agents/[id]/+page.svelte | 33 +- src/routes/api/articles/draft/+server.ts | 40 + src/routes/api/articles/media/+server.ts | 80 + src/routes/api/health.test.ts | 178 +- src/routes/api/health/+server.ts | 94 +- src/routes/api/health/database/+server.ts | 14 +- src/routes/api/internal/scheduler/+server.ts | 22 +- .../api/isac/calculation-assistant/+server.ts | 203 +- .../api/isac/mechanic-assistant/+server.ts | 200 +- .../api/isac/mechanic-gap-report/+server.ts | 82 +- .../api/notifications/unread/+server.ts | 24 +- src/routes/auth/discord/+server.ts | 83 +- src/routes/auth/discord/callback/+server.ts | 285 +- src/routes/auth/magic-link/+server.ts | 69 +- .../passkey/authenticate/options/+server.ts | 23 +- .../passkey/authenticate/verify/+server.ts | 134 +- .../auth/passkey/register/options/+server.ts | 59 +- .../auth/passkey/register/verify/+server.ts | 77 +- src/routes/avatars/user/[id]/+server.ts | 39 +- src/routes/builder/+page.server.ts | 868 +- src/routes/builder/+page.svelte | 18 - src/routes/builds/+page.server.ts | 37 +- src/routes/builds/[slug]/+page.server.ts | 112 +- src/routes/builds/[slug]/+page.svelte | 12 +- src/routes/coming-soon/+page.server.ts | 27 +- src/routes/contact/+page.server.ts | 206 +- src/routes/database/+page.server.ts | 68 +- .../database/[category]/+page.server.ts | 1882 +- src/routes/database/[category]/+page.svelte | 13 +- src/routes/guides/+page.server.ts | 39 + src/routes/guides/+page.svelte | 26 +- src/routes/guides/[slug]/+page.server.ts | 5 + src/routes/guides/[slug]/+page.svelte | 2 + src/routes/guides/[slug]/edit/+page.server.ts | 60 + src/routes/guides/[slug]/edit/+page.svelte | 3 + src/routes/guides/new/+page.server.ts | 45 + src/routes/guides/new/+page.svelte | 4 + .../icons/game-data/[type]/[id]/+server.ts | 27 +- src/routes/images/library/[id]/+server.ts | 18 +- src/routes/isac/+layout.server.ts | 142 +- src/routes/isac/+layout.svelte | 16 +- src/routes/isac/+page.server.ts | 29 +- src/routes/isac/attributes/+page.server.ts | 415 +- src/routes/isac/audit/+page.server.ts | 571 +- .../isac/avatar-oversight/+page.server.ts | 230 +- src/routes/isac/brands/+page.server.ts | 550 +- src/routes/isac/brands/+page.svelte | 20 +- src/routes/isac/calculations/+page.server.ts | 1233 +- src/routes/isac/community/+page.server.ts | 2308 +- .../isac/community/guides/+page.server.ts | 370 + src/routes/isac/community/guides/+page.svelte | 7 + .../guides/[requestId]/+page.server.ts | 67 + .../community/guides/[requestId]/+page.svelte | 4 + src/routes/isac/configuration/+page.server.ts | 91 +- .../contributor-guidelines/+page.server.ts | 129 +- .../isac/data-moderation/+page.server.ts | 834 +- src/routes/isac/data-moderation/+page.svelte | 1 + src/routes/isac/gear/+page.server.ts | 935 +- src/routes/isac/gear/+page.svelte | 8 +- src/routes/isac/gearmods/+page.server.ts | 643 +- src/routes/isac/gearmods/+page.svelte | 15 +- src/routes/isac/images/+page.server.ts | 352 +- .../isac/mechanic-reference/+page.server.ts | 26 +- src/routes/isac/metrics/+page.server.ts | 178 +- src/routes/isac/protocols/+page.server.ts | 1022 +- src/routes/isac/protocols/+page.svelte | 17 +- src/routes/isac/roles/+page.server.ts | 520 +- src/routes/isac/site-controls/+page.server.ts | 1560 +- .../bulk-moderation-terms.test.ts | 391 +- src/routes/isac/skillchips/+page.server.ts | 1162 +- src/routes/isac/skillchips/+page.svelte | 17 +- .../isac/specializations/+page.server.ts | 3479 ++- src/routes/isac/specializations/+page.svelte | 27 +- .../isac/system-messages/+page.server.ts | 198 +- src/routes/isac/talents/+page.server.ts | 1262 +- src/routes/isac/talents/+page.svelte | 16 +- src/routes/isac/users/+page.server.ts | 202 +- src/routes/isac/users/[id]/+page.server.ts | 803 +- src/routes/isac/weaponmods/+page.server.ts | 999 +- src/routes/isac/weaponmods/+page.svelte | 16 +- src/routes/isac/weapons/+page.server.ts | 1215 +- src/routes/news/+page.server.ts | 19 + src/routes/news/+page.svelte | 30 +- src/routes/news/[slug]/+page.server.ts | 5 + src/routes/news/[slug]/+page.svelte | 2 + src/routes/news/[slug]/edit/+page.server.ts | 59 + src/routes/news/[slug]/edit/+page.svelte | 3 + src/routes/news/new/+page.server.ts | 31 + src/routes/news/new/+page.svelte | 4 + src/routes/notifications/+page.server.ts | 2 +- src/routes/sign-in/+page.server.ts | 397 +- src/routes/terms/+page.svelte | 25 +- .../contracts/domain-contract-v1.json | 26 +- .../evidence-archive-manifest-v1.schema.json | 1025 +- .../examples/archive/manifest.valid.json | 166 +- .../examples/handoff/isac-handoff.valid.json | 34 +- .../contracts/isac-handoff-v1.schema.json | 436 +- .../screenshot-classification.schema.json | 210 +- .../game_data_capture/web/static/review.css | 524 +- .../game_data_capture/web/static/review.js | 841 +- .../web/templates/index.html | 116 +- tsconfig.json | 36 +- vite.config.ts | 28 +- 288 files changed, 83254 insertions(+), 38351 deletions(-) create mode 100644 .husky/pre-commit create mode 100644 .prettierignore create mode 100644 .prettierrc create mode 100644 scripts/article-transfer.mjs create mode 100644 src/lib/articles/document.test.ts create mode 100644 src/lib/articles/document.ts create mode 100644 src/lib/articles/editor-extensions.ts create mode 100644 src/lib/components/ArticleCard.svelte create mode 100644 src/lib/components/ArticleComposer.svelte create mode 100644 src/lib/components/ArticleContent.svelte create mode 100644 src/lib/components/ArticleDiscussion.svelte create mode 100644 src/lib/components/ArticleEditor.svelte create mode 100644 src/lib/components/ArticlePage.svelte create mode 100644 src/lib/components/BuildEmbed.svelte create mode 100644 src/lib/components/DatabaseDiscussionDialog.svelte create mode 100644 src/lib/components/GameDataDiscussion.svelte create mode 100644 src/lib/game-data-targets.test.ts create mode 100644 src/lib/game-data-targets.ts create mode 100644 src/lib/server/anonymous-visitors.ts create mode 100644 src/lib/server/article-discussions.ts create mode 100644 src/lib/server/article-form.ts create mode 100644 src/lib/server/article-page.ts create mode 100644 src/lib/server/articles.ts create mode 100644 src/lib/server/comment-removal.test.ts create mode 100644 src/lib/server/comment-removal.ts create mode 100644 src/lib/server/game-data-discussions.ts create mode 100644 src/lib/server/scheduled-tasks.ts create mode 100644 src/lib/server/ubisoft-news-client.test.ts create mode 100644 src/lib/server/ubisoft-news-client.ts create mode 100644 src/lib/server/ubisoft-news-import.test.ts create mode 100644 src/lib/server/ubisoft-news-import.ts create mode 100644 src/routes/account/articles/+page.server.ts create mode 100644 src/routes/account/articles/+page.svelte create mode 100644 src/routes/api/articles/draft/+server.ts create mode 100644 src/routes/api/articles/media/+server.ts create mode 100644 src/routes/guides/+page.server.ts create mode 100644 src/routes/guides/[slug]/+page.server.ts create mode 100644 src/routes/guides/[slug]/+page.svelte create mode 100644 src/routes/guides/[slug]/edit/+page.server.ts create mode 100644 src/routes/guides/[slug]/edit/+page.svelte create mode 100644 src/routes/guides/new/+page.server.ts create mode 100644 src/routes/guides/new/+page.svelte create mode 100644 src/routes/isac/community/guides/+page.server.ts create mode 100644 src/routes/isac/community/guides/+page.svelte create mode 100644 src/routes/isac/community/guides/[requestId]/+page.server.ts create mode 100644 src/routes/isac/community/guides/[requestId]/+page.svelte create mode 100644 src/routes/news/+page.server.ts create mode 100644 src/routes/news/[slug]/+page.server.ts create mode 100644 src/routes/news/[slug]/+page.svelte create mode 100644 src/routes/news/[slug]/edit/+page.server.ts create mode 100644 src/routes/news/[slug]/edit/+page.svelte create mode 100644 src/routes/news/new/+page.server.ts create mode 100644 src/routes/news/new/+page.svelte diff --git a/.gitignore b/.gitignore index 3b59b69..cd26108 100644 --- a/.gitignore +++ b/.gitignore @@ -38,4 +38,8 @@ vite.config.js.timestamp-* vite.config.ts.timestamp-* _img/ -tmp/ \ No newline at end of file +tmp/ + +# Python caches +__pycache__/ +*.py[cod] diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..2312dc5 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npx lint-staged diff --git a/.impeccable/critique/2026-07-26T15-49-23Z__src-routes.md b/.impeccable/critique/2026-07-26T15-49-23Z__src-routes.md index 2dfed5a..63a5b1a 100644 --- a/.impeccable/critique/2026-07-26T15-49-23Z__src-routes.md +++ b/.impeccable/critique/2026-07-26T15-49-23Z__src-routes.md @@ -2,29 +2,30 @@ target: application route system total_score: 28 max_score: 40 -na_heuristics: +na_heuristics: p0_count: 0 p1_count: 2 timestamp: 2026-07-26T15-49-23Z slug: src-routes --- + # SHD.build route-system critique ## Design Health Score -| # | Heuristic | Score | Key issue | -|---|---|---:|---| -| 1 | Visibility of system status | 4 | ISAC exposes environment, access, mode, active navigation, and filter state clearly. | -| 2 | Match system / real world | 3 | SHD vocabulary is authentic, but codes such as OSPR and WMOD need plain-language support. | -| 3 | User control and freedom | 3 | Navigation and cancellation patterns are strong; empty destinations and filter clearing are less explicit. | -| 4 | Consistency and standards | 3 | Shared patterns are cohesive, but live search plus Search, static-looking KPI links, and Edit-only rows diverge. | -| 5 | Error prevention | 3 | Guardrails are extensive, but Add/Edit consequence language is not visible before entry. | -| 6 | Recognition rather than recall | 2 | Seventeen ISAC destinations, code labels, and visually identical category cards create scanning burden. | -| 7 | Flexibility and efficiency | 3 | Search, filtering, page sizes, and grouped navigation help experts; one-page registries expose unnecessary controls. | -| 8 | Aesthetic and minimalist design | 2 | The system is disciplined, but cinematic hero scale and equally weighted bordered blocks compete with the task. | -| 9 | Error recovery | 3 | The application has designed error and preservation patterns, though recovery was not exercised in the inspected states. | -| 10 | Help and documentation | 2 | Microcopy exists, but ISAC terms, permissions, and proposal consequences lack contextual help. | -| **Total** | | **28/40** | **Good foundation; focused hierarchy and disclosure work needed.** | +| # | Heuristic | Score | Key issue | +| --------- | ------------------------------- | --------: | ------------------------------------------------------------------------------------------------------------------------ | +| 1 | Visibility of system status | 4 | ISAC exposes environment, access, mode, active navigation, and filter state clearly. | +| 2 | Match system / real world | 3 | SHD vocabulary is authentic, but codes such as OSPR and WMOD need plain-language support. | +| 3 | User control and freedom | 3 | Navigation and cancellation patterns are strong; empty destinations and filter clearing are less explicit. | +| 4 | Consistency and standards | 3 | Shared patterns are cohesive, but live search plus Search, static-looking KPI links, and Edit-only rows diverge. | +| 5 | Error prevention | 3 | Guardrails are extensive, but Add/Edit consequence language is not visible before entry. | +| 6 | Recognition rather than recall | 2 | Seventeen ISAC destinations, code labels, and visually identical category cards create scanning burden. | +| 7 | Flexibility and efficiency | 3 | Search, filtering, page sizes, and grouped navigation help experts; one-page registries expose unnecessary controls. | +| 8 | Aesthetic and minimalist design | 2 | The system is disciplined, but cinematic hero scale and equally weighted bordered blocks compete with the task. | +| 9 | Error recovery | 3 | The application has designed error and preservation patterns, though recovery was not exercised in the inspected states. | +| 10 | Help and documentation | 2 | Microcopy exists, but ISAC terms, permissions, and proposal consequences lack contextual help. | +| **Total** | | **28/40** | **Good foundation; focused hierarchy and disclosure work needed.** | ## Design Specificity Verdict @@ -36,11 +37,12 @@ blocks flatten into a generic dark administrative grid. The deterministic scan found seven source findings across `src/routes`: one warning for the two-pixel semantic notification rail in -`src/routes/account/+page.svelte`, and six advisory colors outside the documented -palette in the homepage and account route. The rail is largely a detector false -positive because it conveys semantic message state, but its colors should still -come from named tokens. The homepage build colors are data-like values rather -than structural CSS, yet they also bypass the maintained palette. +`src/routes/account/+page.svelte`, and six advisory colors outside the +documented palette in the homepage and account route. The rail is largely a +detector false positive because it conveys semantic message state, but its +colors should still come from named tokens. The homepage build colors are +data-like values rather than structural CSS, yet they also bypass the maintained +palette. Mutable browser injection was unavailable, so no visual overlay is claimed. Fresh Chrome tabs, screenshots, DOM snapshots, and computed styles instead @@ -72,8 +74,8 @@ its complexity according to the operator's current task and role. The Attributes screen simultaneously presents 17 sidebar destinations, four system-state blocks, four KPI filters, search, pagination, row-size controls, -six table columns, and repeated Edit actions. Experienced operators can scan -it, but contributors and new Oversight users must decode the whole information +six table columns, and repeated Edit actions. Experienced operators can scan it, +but contributors and new Oversight users must decode the whole information architecture before acting. **Fix:** make sidebar groups collapsible, prioritize modules by role and recent @@ -109,8 +111,8 @@ to a clear secondary path. ### P2 — Empty registries promise the same value as populated registries -Four of eight Database cards show zero records and “Awaiting contributions,” -but look and behave like full destinations. That creates repeated anticipation +Four of eight Database cards show zero records and “Awaiting contributions,” but +look and behave like full destinations. That creates repeated anticipation followed by a low-value endpoint. **Fix:** give empty registries an explicit empty-state anatomy with expected @@ -121,10 +123,10 @@ not imply the same outcome as populated data. ### P2 — Functional contrast and interaction grammar remain uneven -Muted database descriptions, inactive navigation, unit sublabels, and -pagination text recede heavily. KPI filters resemble static metrics, table rows -look selectable while only the small Edit action works, and a Search button -remains beside an input that searches as the user types. +Muted database descriptions, inactive navigation, unit sublabels, and pagination +text recede heavily. KPI filters resemble static metrics, table rows look +selectable while only the small Edit action works, and a Search button remains +beside an input that searches as the user types. **Fix:** reserve the dimmest tone for nonessential metadata, raise informative copy and available navigation one contrast step, make KPI filter state @@ -147,9 +149,9 @@ seven-plus pagination/search controls, and 22 repeated Edit actions. The Database hero creates a confident, premium entry peak. Populated cards promise useful intelligence, but visually equal empty categories produce a disappointment valley. ISAC's “Online / Authorized / Admin” state reassures -operators, then the uniform density creates fatigue with little calm focus. -The most important missing reassurance is a plain statement of what Add/Edit -will do to live data. +operators, then the uniform density creates fatigue with little calm focus. The +most important missing reassurance is a plain statement of what Add/Edit will do +to live data. ## Persona Red Flags diff --git a/.impeccable/design.json b/.impeccable/design.json index f4076f8..a38e457 100644 --- a/.impeccable/design.json +++ b/.impeccable/design.json @@ -8,133 +8,331 @@ "role": "neutral", "displayName": "Operations Black", "canonical": "#0a0c0d", - "tonalRamp": ["#050606", "#0a0c0d", "#171a1b", "#303536", "#555c5e", "#858b8d", "#c1c4c2", "#f0f0eb"] + "tonalRamp": [ + "#050606", + "#0a0c0d", + "#171a1b", + "#303536", + "#555c5e", + "#858b8d", + "#c1c4c2", + "#f0f0eb" + ] }, "terminal-black": { "role": "neutral", "displayName": "Terminal Black", "canonical": "#0d0f10", - "tonalRamp": ["#050606", "#0d0f10", "#1b1e1f", "#34393a", "#596062", "#888f91", "#c3c7c6", "#f1f2ee"] + "tonalRamp": [ + "#050606", + "#0d0f10", + "#1b1e1f", + "#34393a", + "#596062", + "#888f91", + "#c3c7c6", + "#f1f2ee" + ] }, "terminal-surface": { "role": "neutral", "displayName": "Terminal Surface", "canonical": "#111415", - "tonalRamp": ["#070808", "#111415", "#202425", "#393e3f", "#5f6668", "#8d9496", "#c6cac9", "#f2f3ef"] + "tonalRamp": [ + "#070808", + "#111415", + "#202425", + "#393e3f", + "#5f6668", + "#8d9496", + "#c6cac9", + "#f2f3ef" + ] }, "terminal-graphite": { "role": "neutral", "displayName": "Terminal Graphite", "canonical": "#141718", - "tonalRamp": ["#080a0a", "#111415", "#202425", "#343a3b", "#565e60", "#858d8f", "#c0c5c3", "#f0f1ed"] + "tonalRamp": [ + "#080a0a", + "#111415", + "#202425", + "#343a3b", + "#565e60", + "#858d8f", + "#c0c5c3", + "#f0f1ed" + ] }, "active-graphite": { "role": "neutral", "displayName": "Active Graphite", "canonical": "#15191a", - "tonalRamp": ["#080a0a", "#15191a", "#252a2b", "#3e4445", "#626a6c", "#909799", "#c8cccb", "#f2f3f0"] + "tonalRamp": [ + "#080a0a", + "#15191a", + "#252a2b", + "#3e4445", + "#626a6c", + "#909799", + "#c8cccb", + "#f2f3f0" + ] }, "modeled-data-surface": { "role": "neutral", "displayName": "Modeled Data Surface", "canonical": "#0d1213", - "tonalRamp": ["#050708", "#0d1213", "#1c2527", "#334043", "#526165", "#7d8b8e", "#bac2c2", "#eff1ef"] + "tonalRamp": [ + "#050708", + "#0d1213", + "#1c2527", + "#334043", + "#526165", + "#7d8b8e", + "#bac2c2", + "#eff1ef" + ] }, "structural-line": { "role": "neutral", "displayName": "Structural Line", "canonical": "#303536", - "tonalRamp": ["#0d0f10", "#1b1f20", "#303536", "#4b5152", "#6a7274", "#92999b", "#c3c8c7", "#f0f1ee"] + "tonalRamp": [ + "#0d0f10", + "#1b1f20", + "#303536", + "#4b5152", + "#6a7274", + "#92999b", + "#c3c8c7", + "#f0f1ee" + ] }, "structural-line-soft": { "role": "neutral", "displayName": "Structural Line Soft", "canonical": "#272c2d", - "tonalRamp": ["#0b0d0d", "#181b1c", "#272c2d", "#43494a", "#656d6f", "#8e9597", "#c2c6c5", "#eff0ed"] + "tonalRamp": [ + "#0b0d0d", + "#181b1c", + "#272c2d", + "#43494a", + "#656d6f", + "#8e9597", + "#c2c6c5", + "#eff0ed" + ] }, "structural-line-strong": { "role": "neutral", "displayName": "Structural Line Strong", "canonical": "#424849", - "tonalRamp": ["#111415", "#252a2b", "#424849", "#5d6567", "#7c8587", "#a0a7a8", "#c9cdcc", "#f1f2ef"] + "tonalRamp": [ + "#111415", + "#252a2b", + "#424849", + "#5d6567", + "#7c8587", + "#a0a7a8", + "#c9cdcc", + "#f1f2ef" + ] }, "modeled-data-line": { "role": "neutral", "displayName": "Modeled Data Line", "canonical": "#3b4648", - "tonalRamp": ["#0e1314", "#20292b", "#3b4648", "#566366", "#758184", "#9aa3a5", "#c6cbca", "#f0f2ef"] + "tonalRamp": [ + "#0e1314", + "#20292b", + "#3b4648", + "#566366", + "#758184", + "#9aa3a5", + "#c6cbca", + "#f0f2ef" + ] }, "field-manual-white": { "role": "neutral", "displayName": "Field Manual White", "canonical": "#ecebe5", - "tonalRamp": ["#252522", "#44433f", "#66655f", "#898780", "#aaa8a1", "#cac8c1", "#ecebe5", "#faf9f5"] + "tonalRamp": [ + "#252522", + "#44433f", + "#66655f", + "#898780", + "#aaa8a1", + "#cac8c1", + "#ecebe5", + "#faf9f5" + ] }, "telemetry-muted": { "role": "neutral", "displayName": "Telemetry Muted", "canonical": "#858b8d", - "tonalRamp": ["#151718", "#292d2e", "#414647", "#5e6466", "#858b8d", "#a6abad", "#cbd0cf", "#f1f2ef"] + "tonalRamp": [ + "#151718", + "#292d2e", + "#414647", + "#5e6466", + "#858b8d", + "#a6abad", + "#cbd0cf", + "#f1f2ef" + ] }, "telemetry-dim": { "role": "neutral", "displayName": "Telemetry Dim", "canonical": "#5d6567", - "tonalRamp": ["#111314", "#222627", "#373c3d", "#4b5254", "#5d6567", "#858d8f", "#bcc1c0", "#eef0ed"] + "tonalRamp": [ + "#111314", + "#222627", + "#373c3d", + "#4b5254", + "#5d6567", + "#858d8f", + "#bcc1c0", + "#eef0ed" + ] }, "signal-orange": { "role": "primary", "displayName": "Signal Orange", "canonical": "#f6a821", - "tonalRamp": ["#2b1a05", "#4c2f08", "#754a0d", "#a66c14", "#d7901c", "#f6a821", "#ffd278", "#fff1cf"] + "tonalRamp": [ + "#2b1a05", + "#4c2f08", + "#754a0d", + "#a66c14", + "#d7901c", + "#f6a821", + "#ffd278", + "#fff1cf" + ] }, "signal-orange-hover": { "role": "primary", "displayName": "Signal Orange Hover", "canonical": "#ffc45d", - "tonalRamp": ["#2d1f08", "#51380d", "#785515", "#a97824", "#d99d39", "#ffc45d", "#ffdda0", "#fff3d9"] + "tonalRamp": [ + "#2d1f08", + "#51380d", + "#785515", + "#a97824", + "#d99d39", + "#ffc45d", + "#ffdda0", + "#fff3d9" + ] }, "system-success": { "role": "secondary", "displayName": "System Success", "canonical": "#75c08c", - "tonalRamp": ["#102a19", "#194328", "#275f39", "#3f7d50", "#5c9e6d", "#75c08c", "#a8dcba", "#e2f4e8"] + "tonalRamp": [ + "#102a19", + "#194328", + "#275f39", + "#3f7d50", + "#5c9e6d", + "#75c08c", + "#a8dcba", + "#e2f4e8" + ] }, "system-danger": { "role": "secondary", "displayName": "System Danger", "canonical": "#df8069", - "tonalRamp": ["#32130e", "#542219", "#793428", "#a54a39", "#c9634f", "#df8069", "#edae9f", "#f8dfd9"] + "tonalRamp": [ + "#32130e", + "#542219", + "#793428", + "#a54a39", + "#c9634f", + "#df8069", + "#edae9f", + "#f8dfd9" + ] }, "system-info": { "role": "secondary", "displayName": "System Information", "canonical": "#70b9d6", - "tonalRamp": ["#0d2630", "#163f50", "#245b70", "#387991", "#5599b3", "#70b9d6", "#a6d5e7", "#e1f1f7"] + "tonalRamp": [ + "#0d2630", + "#163f50", + "#245b70", + "#387991", + "#5599b3", + "#70b9d6", + "#a6d5e7", + "#e1f1f7" + ] }, "quality-standard": { "role": "tertiary", "displayName": "Standard Quality", "canonical": "#69b66f", - "tonalRamp": ["#0e2912", "#17431d", "#255e2b", "#397b3e", "#519a56", "#69b66f", "#9dd4a1", "#dff0e0"] + "tonalRamp": [ + "#0e2912", + "#17431d", + "#255e2b", + "#397b3e", + "#519a56", + "#69b66f", + "#9dd4a1", + "#dff0e0" + ] }, "quality-specialized": { "role": "tertiary", "displayName": "Specialized Quality", "canonical": "#4f9ed8", - "tonalRamp": ["#0c2435", "#123c59", "#1d567d", "#2d72a3", "#408dc4", "#4f9ed8", "#8dc5ea", "#d9edf8"] + "tonalRamp": [ + "#0c2435", + "#123c59", + "#1d567d", + "#2d72a3", + "#408dc4", + "#4f9ed8", + "#8dc5ea", + "#d9edf8" + ] }, "quality-superior": { "role": "tertiary", "displayName": "Superior Quality", "canonical": "#9b68d4", - "tonalRamp": ["#21132f", "#38204f", "#522f70", "#704594", "#8959b7", "#9b68d4", "#c3a2e5", "#eee1f7"] + "tonalRamp": [ + "#21132f", + "#38204f", + "#522f70", + "#704594", + "#8959b7", + "#9b68d4", + "#c3a2e5", + "#eee1f7" + ] }, "quality-high-end": { "role": "tertiary", "displayName": "High-end Quality", "canonical": "#e4bd3f", - "tonalRamp": ["#2d2408", "#4c3c0d", "#6f5815", "#95771f", "#bd992d", "#e4bd3f", "#f0d982", "#fbf2ce"] + "tonalRamp": [ + "#2d2408", + "#4c3c0d", + "#6f5815", + "#95771f", + "#bd992d", + "#e4bd3f", + "#f0d982", + "#fbf2ce" + ] } }, "typographyMeta": { diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..cb7e2dd --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +# Ignore artifacts: +build +coverage +tmp +_img \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..2e2ebc6 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,17 @@ +{ + "arrowParens": "always", + "bracketSpacing": true, + "htmlWhitespaceSensitivity": "css", + "insertPragma": false, + "jsxBracketSameLine": false, + "jsxSingleQuote": true, + "printWidth": 80, + "proseWrap": "always", + "quoteProps": "as-needed", + "requirePragma": false, + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "all", + "useTabs": false +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 28d1e67..bdef820 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,3 +1,3 @@ { - "recommendations": ["svelte.svelte-vscode"] + "recommendations": ["svelte.svelte-vscode"] } diff --git a/PRODUCT.md b/PRODUCT.md index 3d6ee61..0d5f9bc 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -8,7 +8,7 @@ web ## Users -SHD.build serves active *The Division Resurgence* players who need reliable game +SHD.build serves active _The Division Resurgence_ players who need reliable game data, accurate loadout planning, build sharing, and community guides. Registered players can save and share builds, publish guides, participate in @@ -21,8 +21,8 @@ administrators manage users, roles, system configuration, and operations. ## Product Purpose -SHD.build is an independent community intelligence platform for *The Division -Resurgence*. It includes a public game database, calculated loadout builder, +SHD.build is an independent community intelligence platform for _The Division +Resurgence_. It includes a public game database, calculated loadout builder, shared builds, guides, news, and community participation. Players should understand game systems, create accurate loadouts, learn from @@ -41,7 +41,8 @@ Player-facing planning and reference experiences use the same moderated data. Players use the site to plan characters, compare drops, reproduce in-game equipment, browse community recommendations, and respond to game updates. The -interface must work from mobile devices through high-resolution desktop displays. +interface must work from mobile devices through high-resolution desktop +displays. The principal public channels are News, Database, Builds, Guides, and Builder. Account management includes authentication methods, profile identity, avatars, @@ -58,12 +59,12 @@ connect proposed records to pending records before approval. - The application is built with SvelteKit and Svelte. - Persistent application data uses the embedded `@tursodatabase/database` package. Turso Cloud is not required. -- Development, staging, and production use distinct databases. Embedded - database files and uploaded assets are persistent user data and must survive +- Development, staging, and production use distinct databases. Embedded database + files and uploaded assets are persistent user data and must survive deployments and additive schema migrations. - Authentication supports passkeys, emailed magic links, Discord OAuth, and - optional username/password credentials. An account must always retain at - least one usable sign-in method. + optional username/password credentials. An account must always retain at least + one usable sign-in method. - Anonymous builder drafts may use local storage. Database-backed sharing requires an account. - Saved builds support private, unlisted, and public visibility. @@ -78,13 +79,13 @@ connect proposed records to pending records before approval. - Major data changes preserve contributor-entered data through verified backups and additive migrations with minimal development-server downtime. - SHD.build is an independent fan site. Ubisoft and the relevant owners retain - rights to *Tom Clancy's The Division*, *The Division Resurgence*, and their + rights to _Tom Clancy's The Division_, _The Division Resurgence_, and their associated intellectual property and assets. ## Brand Commitments The product name is `SHD.build`, with `SHD` capitalized. The product serves the -full *The Division Resurgence* community, including loadout planning. +full _The Division Resurgence_ community, including loadout planning. Use restrained in-universe operational language such as agent, ISAC, Oversight, intelligence, access, transmission, and registry when it improves comprehension. @@ -134,8 +135,8 @@ identity controls must follow their respective provider requirements. SHD.build targets WCAG 2.2 AA. Interfaces must support keyboard navigation, clear focus-visible states, screen-reader semantics, sufficient contrast, -reduced-motion preferences, and layouts that work from mobile screens through -4K desktop displays. +reduced-motion preferences, and layouts that work from mobile screens through 4K +desktop displays. State and meaning cannot depend on color, icons, hover, or motion alone. Controls need usable touch targets. User identity must support non-ASCII display diff --git a/README.md b/README.md index 6909a90..d3e3cc3 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # SHD.build -A SvelteKit community application for *The Division Resurgence*. SHD.build -combines a public game database, loadout builder, shared builds, guides, -account management, and the ISAC data-management console. +A SvelteKit community application for _The Division Resurgence_. SHD.build +combines a public game database, loadout builder, shared builds, guides, account +management, and the ISAC data-management console. See `docs/architecture.md` for the implementation model. @@ -17,13 +17,12 @@ Create or update the local database: npm run db:migrate ``` -The default file is `data/shd-build.db`. Copy `.env.example` to `.env` and -set a different location if needed. +The default file is `data/shd-build.db`. Copy `.env.example` to `.env` and set a +different location if needed. Store custom avatars as metadata-free 256 × 256 WebP files. Set -`TDR_AVATAR_STORAGE_PATH` to a persistent, writable directory in production; -it defaults to `data/avatars`. The same configuration works on Windows and -Linux. +`TDR_AVATAR_STORAGE_PATH` to a persistent, writable directory in production; it +defaults to `data/avatars`. The same configuration works on Windows and Linux. Seed the initial local accounts once: @@ -31,9 +30,9 @@ Seed the initial local accounts once: npm run db:seed-users ``` -The seed creates administrator `Aewyn` and standard user `Quartermaster`. -It generates and displays strong temporary passwords once, then stores only -salted Argon2id hashes. +The seed creates administrator `Aewyn` and standard user `Quartermaster`. It +generates and displays strong temporary passwords once, then stores only salted +Argon2id hashes. ## Develop @@ -74,9 +73,9 @@ game-data icons under `/app/data` on a persistent volume. Inject runtime secrets when the container starts; keep them out of the image. [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) documents the staging Compose topology -and Gitea Actions deployment. A push to `main` validates the application, -builds the candidate image while staging stays online, stops the application -and scheduler for the database backup and migration, then starts the release. +and Gitea Actions deployment. A push to `main` validates the application, builds +the candidate image while staging stays online, stops the application and +scheduler for the database backup and migration, then starts the release. ## Passwordless authentication @@ -136,7 +135,8 @@ npm run build Preview the production build with `npm run preview`. -> Deployments may require an [adapter](https://svelte.dev/docs/kit/adapters) for the target environment. +> Deployments may require an [adapter](https://svelte.dev/docs/kit/adapters) for +> the target environment. The shared visual language and dashboard conventions are documented in [docs/DESIGN_SYSTEM.md](docs/DESIGN_SYSTEM.md). diff --git a/database/schema.sql b/database/schema.sql index 43befe0..170312d 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -138,14 +138,210 @@ CREATE INDEX IF NOT EXISTS oversight_notification_queue_due_idx ON oversight_not CREATE INDEX IF NOT EXISTS oversight_notification_queue_user_category_idx ON oversight_notification_queue(user_id, oversight_category, delivered_at); CREATE INDEX IF NOT EXISTS avatar_reviews_status_submitted_idx ON avatar_reviews(status, submitted_at DESC); CREATE INDEX IF NOT EXISTS avatar_reviews_user_submitted_idx ON avatar_reviews(user_id, submitted_at DESC); - CREATE TABLE IF NOT EXISTS builds (id TEXT PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES users(id), title TEXT NOT NULL, slug TEXT NOT NULL UNIQUE, visibility TEXT NOT NULL DEFAULT 'private', summary TEXT, guide_body TEXT, source_build_id TEXT REFERENCES builds(id), loadout_json TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS builds (id TEXT PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES users(id), title TEXT NOT NULL, slug TEXT NOT NULL UNIQUE, visibility TEXT NOT NULL DEFAULT 'private' CHECK(visibility IN ('private','unlisted','public')), source_build_id TEXT REFERENCES builds(id), loadout_json TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE INDEX IF NOT EXISTS builds_visibility_updated_idx ON builds(visibility, updated_at DESC); CREATE TABLE IF NOT EXISTS build_items (id TEXT PRIMARY KEY, build_id TEXT NOT NULL REFERENCES builds(id) ON DELETE CASCADE, slot_id TEXT NOT NULL, item_definition_id TEXT NOT NULL, configuration_json TEXT NOT NULL); CREATE TABLE IF NOT EXISTS ratings (user_id TEXT NOT NULL REFERENCES users(id), build_id TEXT NOT NULL REFERENCES builds(id) ON DELETE CASCADE, value INTEGER NOT NULL CHECK(value BETWEEN 1 AND 5), PRIMARY KEY(user_id, build_id)); +CREATE TABLE IF NOT EXISTS articles ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL CHECK(kind IN ('guide','news')), + owner_id TEXT NOT NULL REFERENCES users(id), + slug TEXT NOT NULL, + visibility TEXT NOT NULL DEFAULT 'private' CHECK(visibility IN ('private','unlisted','public')), + published_revision_id TEXT REFERENCES article_revisions(id), + pending_revision_id TEXT REFERENCES article_revisions(id), + moderation_hold INTEGER NOT NULL DEFAULT 0 CHECK(moderation_hold IN (0,1)), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + published_at TEXT, + deleted_at TEXT, + deleted_by TEXT REFERENCES users(id), + UNIQUE(kind,slug), + CHECK(published_revision_id IS NOT NULL OR visibility='private'), + CHECK(deleted_at IS NULL OR visibility='private') +); +CREATE INDEX IF NOT EXISTS articles_owner_kind_updated_idx ON articles(owner_id,kind,updated_at DESC); +CREATE INDEX IF NOT EXISTS articles_public_kind_published_idx ON articles(kind,published_at DESC) + WHERE visibility='public' AND deleted_at IS NULL AND published_revision_id IS NOT NULL; +CREATE TABLE IF NOT EXISTS article_drafts ( + article_id TEXT PRIMARY KEY REFERENCES articles(id) ON DELETE CASCADE, + title TEXT NOT NULL DEFAULT '', + deck TEXT NOT NULL DEFAULT '', + document_json TEXT NOT NULL CHECK(json_valid(document_json)), + tags_json TEXT NOT NULL DEFAULT '[]' CHECK(json_valid(tags_json)), + channel TEXT, + cover_image_id TEXT REFERENCES managed_images(id) ON DELETE SET NULL, + cover_alt TEXT NOT NULL DEFAULT '', + cover_caption TEXT NOT NULL DEFAULT '', + version INTEGER NOT NULL DEFAULT 1 CHECK(version>=1), + updated_by TEXT NOT NULL REFERENCES users(id), + updated_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS article_revisions ( + id TEXT PRIMARY KEY, + article_id TEXT NOT NULL REFERENCES articles(id), + revision_number INTEGER NOT NULL CHECK(revision_number>=1), + title TEXT NOT NULL CHECK(length(trim(title)) BETWEEN 2 AND 140), + deck TEXT NOT NULL DEFAULT '', + document_json TEXT NOT NULL CHECK(json_valid(document_json)), + plain_text TEXT NOT NULL, + channel TEXT, + cover_image_id TEXT REFERENCES managed_images(id) ON DELETE SET NULL, + cover_alt TEXT NOT NULL DEFAULT '', + cover_caption TEXT NOT NULL DEFAULT '', + snapshot_hash TEXT NOT NULL CHECK(length(snapshot_hash)=64), + created_from_draft_version INTEGER NOT NULL CHECK(created_from_draft_version>=1), + created_by TEXT NOT NULL REFERENCES users(id), + created_at TEXT NOT NULL, + UNIQUE(article_id,revision_number) +); +CREATE INDEX IF NOT EXISTS article_revisions_article_created_idx ON article_revisions(article_id,created_at DESC); +CREATE TABLE IF NOT EXISTS article_revision_build_embeds ( + revision_id TEXT NOT NULL REFERENCES article_revisions(id) ON DELETE CASCADE, + embed_key TEXT NOT NULL, + ordinal INTEGER NOT NULL CHECK(ordinal>=0), + build_id TEXT NOT NULL REFERENCES builds(id), + owner_shared INTEGER NOT NULL DEFAULT 0 CHECK(owner_shared IN (0,1)), + build_snapshot_json TEXT NOT NULL CHECK(json_valid(build_snapshot_json)), + PRIMARY KEY(revision_id,embed_key), + UNIQUE(revision_id,ordinal) +); +CREATE INDEX IF NOT EXISTS article_revision_build_embeds_build_idx ON article_revision_build_embeds(build_id,revision_id); +CREATE TABLE IF NOT EXISTS article_media ( + article_id TEXT NOT NULL REFERENCES articles(id) ON DELETE CASCADE, + image_id TEXT NOT NULL UNIQUE REFERENCES managed_images(id) ON DELETE CASCADE, + uploaded_by TEXT NOT NULL REFERENCES users(id), + created_at TEXT NOT NULL, + PRIMARY KEY(article_id,image_id) +); +CREATE TABLE IF NOT EXISTS article_tags ( + id TEXT PRIMARY KEY, + group_name TEXT NOT NULL CHECK(group_name IN ('topic','activity','difficulty','specialization','focus','weapon','news_channel')), + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + source_type TEXT, + source_id TEXT, + active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)), + UNIQUE(source_type,source_id) +); +CREATE TABLE IF NOT EXISTS article_revision_tags ( + revision_id TEXT NOT NULL REFERENCES article_revisions(id) ON DELETE CASCADE, + tag_id TEXT NOT NULL REFERENCES article_tags(id), + ordinal INTEGER NOT NULL CHECK(ordinal>=0), + PRIMARY KEY(revision_id,tag_id), + UNIQUE(revision_id,ordinal) +); +CREATE INDEX IF NOT EXISTS article_revision_tags_tag_idx ON article_revision_tags(tag_id,revision_id); +CREATE TABLE IF NOT EXISTS article_publication_requests ( + id TEXT PRIMARY KEY, + article_id TEXT NOT NULL REFERENCES articles(id), + revision_id TEXT NOT NULL REFERENCES article_revisions(id), + base_published_revision_id TEXT REFERENCES article_revisions(id), + requested_visibility TEXT NOT NULL CHECK(requested_visibility IN ('unlisted','public')), + status TEXT NOT NULL CHECK(status IN ('scanning','pending_review','approved','rejected','superseded')), + scan_id TEXT UNIQUE REFERENCES content_moderation_scans(id), + requested_by TEXT NOT NULL REFERENCES users(id), + requested_at TEXT NOT NULL, + decided_by TEXT REFERENCES users(id), + decided_at TEXT, + decision_reason TEXT +); +CREATE UNIQUE INDEX IF NOT EXISTS article_publication_requests_active_idx ON article_publication_requests(article_id) + WHERE status IN ('scanning','pending_review'); +CREATE TABLE IF NOT EXISTS guide_curations ( + article_id TEXT PRIMARY KEY REFERENCES articles(id) ON DELETE CASCADE, + pinned_order INTEGER CHECK(pinned_order IS NULL OR pinned_order>=0), + featured INTEGER NOT NULL DEFAULT 0 CHECK(featured IN (0,1)), + recommended INTEGER NOT NULL DEFAULT 0 CHECK(recommended IN (0,1)), + curated_revision_id TEXT NOT NULL REFERENCES article_revisions(id), + curated_by TEXT NOT NULL REFERENCES users(id), + curated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS guide_curations_discovery_idx ON guide_curations(pinned_order,featured,recommended,curated_at DESC); +CREATE TABLE IF NOT EXISTS anonymous_visitors ( + token_hash TEXT PRIMARY KEY CHECK(length(token_hash)=64), + created_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS anonymous_activity_limits ( + identity_hash TEXT NOT NULL, + action_type TEXT NOT NULL CHECK(action_type IN ('guide_rating','guide_report')), + window_started_at TEXT NOT NULL, + action_count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY(identity_hash,action_type) +); +CREATE TABLE IF NOT EXISTS guide_ratings ( + id TEXT PRIMARY KEY, + article_id TEXT NOT NULL REFERENCES articles(id) ON DELETE CASCADE, + revision_id TEXT NOT NULL REFERENCES article_revisions(id), + rater_user_id TEXT REFERENCES users(id), + anonymous_token_hash TEXT REFERENCES anonymous_visitors(token_hash), + value INTEGER NOT NULL CHECK(value BETWEEN 1 AND 5), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + CHECK((rater_user_id IS NOT NULL AND anonymous_token_hash IS NULL) OR (rater_user_id IS NULL AND anonymous_token_hash IS NOT NULL)) +); +CREATE UNIQUE INDEX IF NOT EXISTS guide_ratings_user_idx ON guide_ratings(article_id,rater_user_id) WHERE rater_user_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS guide_ratings_anonymous_idx ON guide_ratings(article_id,anonymous_token_hash) WHERE anonymous_token_hash IS NOT NULL; +CREATE INDEX IF NOT EXISTS guide_ratings_article_idx ON guide_ratings(article_id,value); +CREATE TABLE IF NOT EXISTS article_search ( + article_id TEXT PRIMARY KEY REFERENCES articles(id) ON DELETE CASCADE, + title TEXT NOT NULL, + deck TEXT NOT NULL, + body TEXT NOT NULL, + tags TEXT NOT NULL +); +INSERT OR IGNORE INTO users(id,username,role,created_at) VALUES( + '7e2a8a20-9bbd-4f1e-9124-a3fb71f7de55','ubisoft-news-service','user','2026-08-01T00:00:00.000Z' +); +INSERT OR IGNORE INTO user_profiles(user_id,display_name,is_public,updated_at) VALUES( + '7e2a8a20-9bbd-4f1e-9124-a3fb71f7de55','Ubisoft Official News',0,'2026-08-01T00:00:00.000Z' +); +CREATE TABLE IF NOT EXISTS external_news_sources ( + article_id TEXT PRIMARY KEY REFERENCES articles(id) ON DELETE CASCADE, + provider TEXT NOT NULL CHECK(provider IN ('ubisoft')), + external_id TEXT NOT NULL, + canonical_url TEXT NOT NULL, + source_published_at TEXT NOT NULL, + content_hash TEXT NOT NULL CHECK(length(content_hash)=64), + first_seen_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + last_synced_at TEXT NOT NULL, + UNIQUE(provider,external_id) +); +CREATE INDEX IF NOT EXISTS external_news_sources_last_seen_idx + ON external_news_sources(provider,last_seen_at DESC); +CREATE TABLE IF NOT EXISTS external_news_sync_state ( + provider TEXT PRIMARY KEY CHECK(provider IN ('ubisoft')), + last_attempt_at TEXT, + last_success_at TEXT, + next_check_at TEXT NOT NULL, + consecutive_failures INTEGER NOT NULL DEFAULT 0 CHECK(consecutive_failures>=0), + last_error TEXT +); +INSERT OR IGNORE INTO article_tags(id,group_name,name,slug,source_type,source_id,active) VALUES + ('guide-builds','topic','Builds','builds','static','builds',1), + ('guide-pve','activity','PvE','pve','static','pve',1), + ('guide-pvp','activity','PvP','pvp','static','pvp',1), + ('guide-dark-zone','activity','Dark Zone','dark-zone','static','dark-zone',1), + ('guide-conflict','activity','Conflict','conflict','static','conflict',1), + ('guide-elite','difficulty','Elite','elite','static','elite',1), + ('guide-heroic','difficulty','Heroic','heroic','static','heroic',1), + ('guide-shotgun','weapon','Shotgun','shotgun','weapon_category','Shotgun',1), + ('guide-rifle','weapon','Rifle','rifle','weapon_category','Rifle',1), + ('guide-mmr','weapon','MMR','mmr','weapon_category','MMR',1), + ('guide-ar','weapon','AR','ar','weapon_category','AR',1), + ('guide-lmg','weapon','LMG','lmg','weapon_category','LMG',1), + ('guide-smg','weapon','SMG','smg','weapon_category','SMG',1), + ('news-official','news_channel','Official Game News','official-game-news','news_channel','official',1), + ('news-patch','news_channel','Patch & Data Updates','patch-data-updates','news_channel','patch',1), + ('news-community','news_channel','Community Reports','community-reports','news_channel','community',1), + ('news-shd','news_channel','SHD.build Development','shd-build-development','news_channel','shd',1); CREATE TABLE IF NOT EXISTS comment_threads ( id TEXT PRIMARY KEY, - target_type TEXT NOT NULL CHECK(target_type IN ('build', 'guide', 'profile')), + target_type TEXT NOT NULL CHECK(target_type IN ('build', 'guide', 'news', 'profile', 'game_data')), target_id TEXT NOT NULL, + pinned_comment_id TEXT REFERENCES comments(id) ON DELETE SET NULL, created_at TEXT NOT NULL, UNIQUE(target_type, target_id) ); @@ -267,6 +463,7 @@ CREATE TABLE IF NOT EXISTS content_moderation_scans ( author_id TEXT NOT NULL REFERENCES users(id), content_hash TEXT NOT NULL CHECK(length(content_hash)>0), revision_id TEXT REFERENCES moderated_content_revisions(id), + article_revision_id TEXT REFERENCES article_revisions(id), policy_version INTEGER NOT NULL CHECK(policy_version>=1), provider TEXT NOT NULL CHECK(length(trim(provider))>0), model TEXT NOT NULL CHECK(length(trim(model))>0), @@ -281,10 +478,10 @@ CREATE TABLE IF NOT EXISTS content_moderation_scans ( completed_at TEXT, error_code TEXT, error_message TEXT, - UNIQUE(content_type,content_id,content_hash,policy_version,provider,model), CHECK( - (content_type IN ('comment','avatar') AND revision_id IS NULL) - OR (content_type IN ('profile_bio','guide') AND revision_id IS NOT NULL) + (content_type IN ('comment','avatar') AND revision_id IS NULL AND article_revision_id IS NULL) + OR (content_type='profile_bio' AND revision_id IS NOT NULL AND article_revision_id IS NULL) + OR (content_type='guide' AND revision_id IS NULL AND article_revision_id IS NOT NULL) ), CHECK( (status='error' AND error_code IS NOT NULL) @@ -296,6 +493,13 @@ CREATE INDEX IF NOT EXISTS content_moderation_scans_pending_queue_idx WHERE status IN ('review','priority_review'); CREATE INDEX IF NOT EXISTS content_moderation_scans_content_idx ON content_moderation_scans(content_type,content_id,created_at DESC); +CREATE UNIQUE INDEX IF NOT EXISTS content_moderation_scans_direct_unique_idx + ON content_moderation_scans(content_type,content_id,content_hash,policy_version,provider,model) + WHERE content_type IN ('comment','avatar'); +CREATE UNIQUE INDEX IF NOT EXISTS content_moderation_scans_profile_unique_idx + ON content_moderation_scans(revision_id,policy_version,provider,model) WHERE content_type='profile_bio'; +CREATE UNIQUE INDEX IF NOT EXISTS content_moderation_scans_guide_unique_idx + ON content_moderation_scans(article_revision_id,policy_version,provider,model) WHERE content_type='guide'; CREATE TABLE IF NOT EXISTS content_moderation_reviews ( id TEXT PRIMARY KEY, scan_id TEXT NOT NULL REFERENCES content_moderation_scans(id), @@ -308,11 +512,14 @@ CREATE INDEX IF NOT EXISTS content_moderation_reviews_scan_created_idx ON content_moderation_reviews(scan_id,created_at DESC); CREATE INDEX IF NOT EXISTS content_moderation_reviews_reviewer_created_idx ON content_moderation_reviews(reviewer_id,created_at DESC); +CREATE UNIQUE INDEX IF NOT EXISTS content_moderation_reviews_scan_unique_idx ON content_moderation_reviews(scan_id); CREATE TABLE IF NOT EXISTS user_content_reports ( id TEXT PRIMARY KEY, content_type TEXT NOT NULL CHECK(content_type IN ('comment','profile_bio','guide','avatar')), content_id TEXT NOT NULL, - reporter_id TEXT NOT NULL REFERENCES users(id), + reporter_id TEXT REFERENCES users(id), + anonymous_token_hash TEXT REFERENCES anonymous_visitors(token_hash), + article_revision_id TEXT REFERENCES article_revisions(id), category TEXT NOT NULL CHECK(category IN ( 'spam','harassment','hate','sexual','violence','self_harm','privacy','other' )), @@ -322,6 +529,8 @@ CREATE TABLE IF NOT EXISTS user_content_reports ( reviewed_at TEXT, reviewed_by TEXT REFERENCES users(id), resolution_reason TEXT, + CHECK((reporter_id IS NOT NULL AND anonymous_token_hash IS NULL) OR (reporter_id IS NULL AND anonymous_token_hash IS NOT NULL)), + CHECK((content_type='guide' AND article_revision_id IS NOT NULL) OR (content_type<>'guide' AND article_revision_id IS NULL)), CHECK( (status='pending' AND reviewed_at IS NULL AND reviewed_by IS NULL AND resolution_reason IS NULL) OR (status IN ('resolved','dismissed') AND reviewed_at IS NOT NULL AND reviewed_by IS NOT NULL @@ -335,7 +544,10 @@ CREATE INDEX IF NOT EXISTS user_content_reports_content_idx ON user_content_reports(content_type,content_id,created_at DESC); CREATE UNIQUE INDEX IF NOT EXISTS user_content_reports_one_pending_idx ON user_content_reports(reporter_id,content_type,content_id) - WHERE status='pending'; + WHERE status='pending' AND reporter_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS user_content_reports_one_anonymous_pending_idx + ON user_content_reports(anonymous_token_hash,content_type,content_id) + WHERE status='pending' AND anonymous_token_hash IS NOT NULL; CREATE TABLE IF NOT EXISTS favorites (user_id TEXT NOT NULL REFERENCES users(id), build_id TEXT NOT NULL REFERENCES builds(id) ON DELETE CASCADE, created_at TEXT NOT NULL, PRIMARY KEY(user_id, build_id)); CREATE TABLE IF NOT EXISTS equipment_types (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, category TEXT NOT NULL); CREATE TABLE IF NOT EXISTS stat_definitions (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, value_kind TEXT NOT NULL, min_value REAL, max_value REAL, unit TEXT, active INTEGER NOT NULL DEFAULT 1); @@ -453,6 +665,9 @@ INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-images INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-avatars-review','avatars.review','Review and remove custom user avatars'); INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-community-comments-moderate','community.comments.moderate','Review and moderate public build, guide, and agent-profile comments'); INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-community-profiles-moderate','community.profiles.moderate','Review and hide public agent-profile biographies'); +INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-community-guides-moderate','community.guides.moderate','Review, hide, and restore public Guide revisions'); +INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-guides-curate','guides.curate','Pin, feature, and recommend published Guides'); +INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-news-manage','news.manage','Create, edit, publish, and remove News articles'); INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-calculation-publish','calculation.publish','Activate combat calculation revisions used by every loadout'); INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-ai-mechanic-generate','ai.mechanic.generate','Use the OpenAI-assisted mechanic drafting workflow'); INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-ai-calculation-generate','ai.calculation.generate','Use the OpenAI-assisted combat calculation revision drafting workflow'); @@ -490,6 +705,8 @@ INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-moder INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-moderator','permission-avatars-review'); INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-moderator','permission-community-comments-moderate'); INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-moderator','permission-community-profiles-moderate'); +INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-moderator','permission-community-guides-moderate'); +INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-moderator','permission-guides-curate'); CREATE TABLE IF NOT EXISTS oauth_states (state_hash TEXT PRIMARY KEY, provider TEXT NOT NULL, mode TEXT NOT NULL, user_id TEXT REFERENCES users(id) ON DELETE CASCADE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS data_change_proposals ( id TEXT PRIMARY KEY, @@ -499,6 +716,7 @@ CREATE TABLE IF NOT EXISTS data_change_proposals ( draft_entity_id TEXT, applied_entity_id TEXT, summary TEXT NOT NULL, + contributor_note TEXT, payload_json TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected','failed')), proposed_by TEXT NOT NULL REFERENCES users(id), diff --git a/deploy/compose.production.yml b/deploy/compose.production.yml index 0bdda30..361b97e 100644 --- a/deploy/compose.production.yml +++ b/deploy/compose.production.yml @@ -11,7 +11,7 @@ services: HOST: 0.0.0.0 PORT: 3000 BODY_SIZE_LIMIT: 6M - SKIP_DEFAULT_USERS: "true" + SKIP_DEFAULT_USERS: 'true' TDR_DATABASE_PATH: /app/data/shd-build-production.db TDR_AVATAR_STORAGE_PATH: /app/data/avatars TDR_GAME_ICON_STORAGE_PATH: /app/data/game-icons @@ -20,8 +20,9 @@ services: ORIGIN: https://shd.build PASSKEY_RP_ID: shd.build DISCORD_REDIRECT_URI: https://shd.build/auth/discord/callback + UBISOFT_NEWS_IMPORT_ENABLED: 'true' ports: - - "0.0.0.0:${SHD_PRODUCTION_PORT:-3102}:3000" + - '0.0.0.0:${SHD_PRODUCTION_PORT:-3102}:3000' volumes: - shd_production_data:/app/data healthcheck: diff --git a/deploy/compose.staging.yml b/deploy/compose.staging.yml index 92425af..80e1bf3 100644 --- a/deploy/compose.staging.yml +++ b/deploy/compose.staging.yml @@ -11,13 +11,14 @@ services: HOST: 0.0.0.0 PORT: 3000 BODY_SIZE_LIMIT: 6M - SKIP_DEFAULT_USERS: "true" + SKIP_DEFAULT_USERS: 'true' TDR_DATABASE_PATH: /app/data/shd-build-staging.db TDR_AVATAR_STORAGE_PATH: /app/data/avatars TDR_GAME_ICON_STORAGE_PATH: /app/data/game-icons SHD_BUILD_NUMBER: ${SHD_BUILD_NUMBER:-0} + UBISOFT_NEWS_IMPORT_ENABLED: 'false' ports: - - "0.0.0.0:${SHD_STAGE_PORT:-5569}:3000" + - '0.0.0.0:${SHD_STAGE_PORT:-5569}:3000' volumes: - shd_stage_data:/app/data healthcheck: diff --git a/docs/CONTENT_MODERATION.md b/docs/CONTENT_MODERATION.md index b4653f8..c3581cc 100644 --- a/docs/CONTENT_MODERATION.md +++ b/docs/CONTENT_MODERATION.md @@ -1,14 +1,20 @@ # Community content moderation SHD.build applies one moderation lifecycle to public comments, profile -biographies, custom avatars, guides, and build descriptions. The shared server -service lives in `src/lib/server/community-moderation.ts`. Content save actions -must use that service before publication. +biographies, custom avatars, and Guides. The shared server service lives in +`src/lib/server/community-moderation.ts`. Content save actions must use that +service before publication. + +Public database records also host one moderated discussion thread. The thread +uses the existing `comment` content type and accepts only visible comments from +authenticated Agents. Community moderators may pin one visible comment as the +record's Community Note. Pinning and unpinning append `community_note.pinned` +and `community_note.unpinned` audit events; a pinned comment remains ordinary +community text and follows the same removal rules as every other comment. ## Publication decisions -The moderation service combines local rules with the configured provider -result: +The moderation service combines local rules with the configured provider result: - Clean content publishes without human review when automatic publication is enabled. @@ -18,8 +24,8 @@ result: enabled. - An allow term can suppress the matching local term rule. It cannot override a provider flag or threshold. -- A user report sends visible content to Oversight. One report does not hide - the content because reports can be mistaken or abusive. +- A user report sends visible content to Oversight. One report does not hide the + content because reports can be mistaken or abusive. The service records every automated scan in `content_moderation_scans`. Automated scans do not write to the human ISAC audit log. Human policy changes @@ -33,13 +39,20 @@ Comments use an explicit status: - `pending_review`: hidden while Oversight decides - `removed`: unavailable after author removal or an Oversight decision -Long-form content uses immutable pending revisions. Profile and guide edits that -need review go into `moderated_content_revisions`; the public row keeps its last -approved text. Approval copies the reviewed revision into the public row. -Rejection preserves the revision and review evidence without publishing it. +Long-form content uses immutable pending revisions. Profile biographies keep +text candidates in `moderated_content_revisions`. Guides use the complete +structured snapshot in `article_revisions`, including title, deck, document, +tags, media references, and Build snapshots. The public article keeps its last +approved revision while another revision waits for review. Rejection retains the +submitted revision and evidence without publishing it. -Custom avatar uploads remain active after a clean image scan. A flagged image -or fail-closed scan error creates an `avatar_reviews` item and is unavailable +News articles do not use automated content moderation. The `news.manage` +permission gates News creation, editing, publication, and deletion. Comments on +News articles remain community content and use the standard comment moderation +lifecycle. + +Custom avatar uploads remain active after a clean image scan. A flagged image or +fail-closed scan error creates an `avatar_reviews` item and is unavailable through the public avatar route until approval. An Agent report also creates a review item, but leaves an already-published avatar visible while Oversight decides. @@ -69,8 +82,8 @@ applicable, and records a specific ISAC audit event with before and after evidence. Unsaved changes in other categories remain local drafts. Site Controls requires `site.settings.manage`. Community Oversight handles text -decisions through the existing comment and profile permissions. Avatar -Oversight handles custom images through `avatars.review`. +decisions through the existing comment and profile permissions. Avatar Oversight +handles custom images through `avatars.review`. Moderators must record a reason when they approve, reject, remove, restore, dismiss, or resolve a queued item. The action must append an audit event after @@ -78,10 +91,14 @@ the content and review records change. ## User reports -Authenticated Agents can report visible content they do not own. The report -control must identify the content type and stable content ID, validate that the -content remains public, and create a pending `user_content_reports` row. -Duplicate pending reports from the same Agent for the same content are rejected. +Authenticated Agents can report visible comments, biographies, and avatars they +do not own. Registered and anonymous visitors can report visible Guides. A Guide +report records the exact published `article_revision_id`; a decision cannot hide +a newer revision. Anonymous reports use a random HTTP-only browser token whose +domain-separated SHA-256 hash supplies duplicate suppression. The server applies +rate limits and requests Turnstile verification after repeated anonymous +activity. SHD.build does not use device fingerprinting or retain a raw IP +address as the reporter identity. A reviewer either dismisses the report and keeps the content or resolves the report through a moderation action. Report details must not copy authentication @@ -89,8 +106,7 @@ secrets or unnecessary personal information into the audit log. ## Adding a public UGC field -Guide authoring, build descriptions, and later public text fields must complete -this checklist: +Guide authoring and later public text fields must complete this checklist: 1. Normalize and validate the submitted text before scanning. 2. Call `moderateCommunityText` with a supported content type. @@ -98,14 +114,15 @@ this checklist: 4. Publish only an `approved` result. 5. Store held long-form text as an immutable revision so the last approved version remains public. -6. Call `recordCommunityModerationScan` with the stable content ID, author, hash, - policy version, provider evidence, and local-rule matches. +6. Call `recordCommunityModerationScan` with the stable content ID, exact + article revision, author, hash, policy version, provider evidence, and + local-rule matches. 7. Add the content type to the Community Oversight queue and permission checks. 8. Add the shared report control to the public rendering. 9. Delay follower, owner, or reply notifications until the content becomes public. 10. Add approval, rejection, report dismissal, author notification, and audit - tests. + tests. 11. Confirm every public query selects approved content explicitly. Do not add a route-specific word filter, provider client, threshold table, or @@ -127,9 +144,9 @@ option, update the legal copy before sending user content under that setting. Threshold changes increment the policy version. Stored scans retain their model and policy version so reviewers can interpret older decisions. Test threshold -changes against representative Division terminology before activation, -including weapon names, damage descriptions, kills, status effects, and build -strategy language. +changes against representative Division terminology before activation, including +weapon names, damage descriptions, kills, status effects, and build strategy +language. Database migrations that add moderation states or revision storage follow the repository database-preservation procedure: stop this project's development diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 4f48034..14c3b0c 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -2,8 +2,8 @@ SHD.build runs in a Node 24 container through the official SvelteKit Node adapter. The image includes application code, migration and notification -scripts, and native runtime dependencies. Environment files, databases, -backups, and user uploads remain outside the image. +scripts, and native runtime dependencies. Environment files, databases, backups, +and user uploads remain outside the image. ## Persistent data @@ -16,8 +16,8 @@ mounted at `/app/data`. It contains: - uploaded game-data icons under `game-icons/`. Keep this volume during routine deployments. `docker compose down` without -`--volumes` preserves it. Before a manual backup or restore, stop the `app` -and `scheduler` services because both access the embedded database. +`--volumes` preserves it. Before a manual backup or restore, stop the `app` and +`scheduler` services because both access the embedded database. ## Staging environment @@ -29,6 +29,21 @@ Staging defaults to: - Passkey relying-party ID: `shd.build` - Adapter canonical origin: `https://stage.shd.build` +Guide ratings and reports use Cloudflare Turnstile after repeated anonymous +activity. Configure both values in each encrypted environment: + +```text +TURNSTILE_SITE_KEY= +TURNSTILE_SECRET_KEY= +``` + +Register the exact staging and production hostnames in the Turnstile widget. The +browser receives only `TURNSTILE_SITE_KEY`. The server sends a challenge token +and the request address to Cloudflare for verification; it never exposes +`TURNSTILE_SECRET_KEY` or stores the raw address as visitor identity. Without a +valid secret, a visitor who reaches the challenge threshold cannot submit more +anonymous ratings or Guide reports. + Edit its encrypted configuration locally: ```sh @@ -38,12 +53,11 @@ npm run env:encrypt:staging ``` Add `https://stage.shd.build/auth/discord/callback` to the Discord application's -allowed OAuth redirect URIs. Point the staging DNS record at the deployment -host and configure the host's TLS reverse proxy to forward -`stage.shd.build` to port `5569` on the staging host. Docker publishes this -port on the host interfaces so a containerized reverse proxy or tunnel can -reach it; firewall the port to trusted LAN/tunnel sources when the host is -reachable from other networks. +allowed OAuth redirect URIs. Point the staging DNS record at the deployment host +and configure the host's TLS reverse proxy to forward `stage.shd.build` to port +`5569` on the staging host. Docker publishes this port on the host interfaces so +a containerized reverse proxy or tunnel can reach it; firewall the port to +trusted LAN/tunnel sources when the host is reachable from other networks. The trusted staging runner decrypts `.env.staging` into a mode-`0600` runtime file. Gitea Actions holds the decryption key; Git and the container image do @@ -55,10 +69,10 @@ that signs in instead of writing generated credentials into deployment logs. ## Gitea Actions runner -Gitea Actions requires an `act_runner`. Register a trusted runner on the -staging host from the repository's **Settings → Actions → Runners** page. The -host needs Node/npm, Docker Engine, and Docker Compose v2. The runner account -must be able to use Docker and write to the deployment directory. +Gitea Actions requires an `act_runner`. Register a trusted runner on the staging +host from the repository's **Settings → Actions → Runners** page. The host needs +Node/npm, Docker Engine, and Docker Compose v2. The runner account must be able +to use Docker and write to the deployment directory. Use runner labels that provide an isolated validation job and a host deployment job: @@ -94,9 +108,9 @@ In `https://git.jb.codes/jb/shd.build/settings/actions`: `.gitea/workflows/deploy-staging.yml` runs for pushes to `main` and manual dispatches. The trusted staging runner builds each commit-tagged image locally, -so the workflow needs no registry credentials. Gitea assigns -`gitea.run_number` to `SHD_BUILD_NUMBER`; each new workflow run receives the -next integer, while retries of the same run retain their number. +so the workflow needs no registry credentials. Gitea assigns `gitea.run_number` +to `SHD_BUILD_NUMBER`; each new workflow run receives the next integer, while +retries of the same run retain their number. ## Deployment lifecycle @@ -114,12 +128,28 @@ The workflow: The outage covers backup, migration, and container startup. Migrations may also remove records that use retired data formats when a release explicitly ends compatibility. The deployment backup remains the recovery source for those -records; the application does not repair or reinterpret them at runtime. -The scheduler calls a bearer-authenticated endpoint on the application service -once per minute. Scheduled message delivery therefore uses the web process's -existing embedded-database connection instead of opening the locked database -from a second process. The endpoint runs automated system messages, Oversight -consolidation, and email digest checks and is not published through a host port. +records; the application does not repair or reinterpret them at runtime. The +scheduler calls a bearer-authenticated endpoint on the application service once +per minute. Scheduled message delivery therefore uses the web process's existing +embedded-database connection instead of opening the locked database from a +second process. The endpoint runs automated system messages, Oversight +consolidation, email digest checks, and the Ubisoft News importer. Docker does +not publish the endpoint through a host port. + +When enabled in production, the Ubisoft importer keeps its own persisted due +time, so the one-minute tick starts a feed synchronization no more than once per +hour after a successful run. When a synchronization fails, the importer stores +the last error and consecutive failure count, then retries after 15 minutes with +exponential backoff capped at six hours. Source items that disappear from the +feed remain published; the importer performs no automatic removal. The scheduler +runs notification delivery before it starts the bounded Ubisoft request, so a +slow provider does not postpone notifications for the current tick. + +`deploy/compose.production.yml` declares `UBISOFT_NEWS_IMPORT_ENABLED=true`, +while the staging Compose file declares `false`. Development does not set the +flag and remains disabled because the importer requires the exact value `true`. +The application process evaluates the flag when it handles +`/api/internal/scheduler`. ## Prelaunch operation @@ -131,8 +161,8 @@ for a database that does not contain the row: - News, Database, Builds, Guides, and Builder: enabled The migration uses an insert-if-missing seed. Later deployments preserve the -operator's settings, and configuration changes take effect without a restart. -A full environment bootstrap copies the source row. Canonical ISAC game-data +operator's settings, and configuration changes take effect without a restart. A +full environment bootstrap copies the source row. Canonical ISAC game-data imports leave the target environment's launch settings unchanged. Use this operator flow after the first deployment: @@ -149,13 +179,13 @@ Use this operator flow after the first deployment: session before public release. Sign in and verify the same disabled routes remain unavailable. -Keep Coming Soon enabled while preparing production. Section controls can be -set in advance because the launch gate takes precedence for signed-out traffic. -Set Account Creation to Disabled if prelaunch access must remain limited to -existing identities. Switch the Launch Gate to Public Launch after verification. -Disabled sections remain absent from navigation and unavailable by direct URL -for every user, including authenticated agents. Authentication bypasses only -the Coming Soon gate. +Keep Coming Soon enabled while preparing production. Section controls can be set +in advance because the launch gate takes precedence for signed-out traffic. Set +Account Creation to Disabled if prelaunch access must remain limited to existing +identities. Switch the Launch Gate to Public Launch after verification. Disabled +sections remain absent from navigation and unavailable by direct URL for every +user, including authenticated agents. Authentication bypasses only the Coming +Soon gate. Turn Coming Soon on to restore the promotional gate. Site-control changes append `system_configuration / site_access.updated` audit events, including the @@ -177,9 +207,9 @@ open while its complete transfer bundle is created: npm run stage:bootstrap:export ``` -The command writes a timestamped, ignored JSON bundle under `data/backups/`. -The bundle includes user records and password hashes. Transfer it over an -encrypted channel, keep it out of Git, then copy it to the staging host and run: +The command writes a timestamped, ignored JSON bundle under `data/backups/`. The +bundle includes user records and password hashes. Transfer it over an encrypted +channel, keep it out of Git, then copy it to the staging host and run: ```sh /opt/shd-build-stage/bootstrap-staging.sh \ @@ -187,11 +217,10 @@ encrypted channel, keep it out of Git, then copy it to the staging host and run: ``` The bootstrap flow stops the app and scheduler, creates a safety backup of the -target, verifies that staging is unused, imports the entire development -database and uploads, and restarts both services. Delete the transfer copy from -the staging host after verifying the imported record counts and critical -entities. The copy under the local ignored backup directory remains the -recovery source. +target, verifies that staging is unused, imports the entire development database +and uploads, and restarts both services. Delete the transfer copy from the +staging host after verifying the imported record counts and critical entities. +The copy under the local ignored backup directory remains the recovery source. Because this is a full initial clone, development session tokens and account data also enter staging. Staging and development remain independent from that @@ -293,9 +322,31 @@ Copy the resulting ignored JSON bundle to the deployment host over SSH, then: /secure/path/shd-production-bootstrap-YYYY-MM-DDTHH-MM-SSZ.json ``` -The importer verifies the bundle target, creates a production safety backup, -and refuses to replace a production database containing users, builds, audit -events, or proposals. Remove the remote transfer copy after verification. +The importer verifies the bundle target, creates a production safety backup, and +refuses to replace a production database containing users, builds, audit events, +or proposals. Remove the remote transfer copy after verification. + +### Promote one News article + +Export a locally authored News article by ID or slug while the development +server is stopped: + +```sh +npm run article:export -- +``` + +The ignored bundle contains the article draft, immutable revisions, search row, +managed-image metadata, and image bytes. It excludes comments, reports, user +records, Builds, and ISAC data. + +After deploying the matching application schema, stop the production app and +scheduler and mount the transferred bundle into a one-off application container. +Run `scripts/article-transfer.mjs import` with `ARTICLE_IMPORT_ENABLED=true` and +`--confirm-article-import`. The importer creates a production database backup +before opening the database, resolves the article owner by stable ID or +username, rejects ID and slug conflicts, verifies image checksums, and appends +`editorial_content / news.promoted`. Restart and health-check both services, +then remove the transferred bundle from the host. ### Pull production ISAC data into development @@ -312,10 +363,10 @@ notifications, settings, and audit history untouched. ### Pull production ISAC data into staging -Manually run **Replace staging ISAC data from production** in Gitea Actions. -The workflow exports a consistent production snapshot, restarts production, -then stops staging and imports it. The staging importer creates full and -ISAC-only safety backups before replacement. Deployments do not run this flow. +Manually run **Replace staging ISAC data from production** in Gitea Actions. The +workflow exports a consistent production snapshot, restarts production, then +stops staging and imports it. The staging importer creates full and ISAC-only +safety backups before replacement. Deployments do not run this flow. ### Promote managed images without replacing other data @@ -333,8 +384,8 @@ internal scheduler. `REMOVE_BG_CREDIT_CHECK_HOURS` controls the account lookup interval and defaults to six hours. Low-capacity alerts require both remaining pools to cross their thresholds: `REMOVE_BG_LOW_CREDIT_THRESHOLD` defaults to five paid credits, and `REMOVE_BG_LOW_FREE_CALL_THRESHOLD` defaults to ten free -calls. Alerts are mandatory ISAC notifications for active Administrators and -are deduplicated for each warning or critical episode. +calls. Alerts are mandatory ISAC notifications for active Administrators and are +deduplicated for each warning or critical episode. ```sh npm run images:export @@ -368,8 +419,8 @@ adds new stable IDs, skips identical assets, and blocks conflicting records. After reviewing an intentional metadata or file replacement, add `--update-existing`. Imported changes append `image.imported` or `image.import_updated` audit events. Remove the transferred bundle after -verifying both environments because it may contain unpublished visual assets -and creator identifiers. +verifying both environments because it may contain unpublished visual assets and +creator identifiers. The `.env.staging` and `.env.production` npm import commands are intended for maintenance performed directly in an environment where those database and diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index 5b7e9ef..26db4e3 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -1,21 +1,26 @@ # SHD.build Design System -This document defines the visual language for SHD.build, an independent -Division Resurgence data-and-tools site for builds, guides, game data, news, -and discussion. New pages use the same tactical intelligence network as the -admin dashboard. Public pages may use more space and larger editorial -typography. +This document defines the visual language for SHD.build, an independent Division +Resurgence data-and-tools site for builds, guides, game data, news, and +discussion. New pages use the same tactical intelligence network as the admin +dashboard. Public pages may use more space and larger editorial typography. ## Design principles -1. **Tactical.** Use operational language, compact information density, restrained status indicators, and precise alignment. Avoid camouflage, distressed textures, and decorative weapon imagery in utility interfaces. -2. **Data first.** Grouping, typography, borders, and spacing establish hierarchy. Admin tools use a dependable control surface. -3. **Orange means action.** SHD orange identifies primary actions, active navigation, focus, and important values. It should not cover large areas. -4. **State is always visible.** Loading, disabled, active, archived, configured, error, and success states need both visual and textual indicators. -5. **Square and engineered.** Panels and controls use square corners, fine borders, tight spacing, and deliberate grid alignment. -6. **Links use interface feedback, not underlines.** Links do not use native text - underlines. Communicate hover and focus through SHD accent color, borders, - brightness, and the required focus-visible outline. +1. **Tactical.** Use operational language, compact information density, + restrained status indicators, and precise alignment. Avoid camouflage, + distressed textures, and decorative weapon imagery in utility interfaces. +2. **Data first.** Grouping, typography, borders, and spacing establish + hierarchy. Admin tools use a dependable control surface. +3. **Orange means action.** SHD orange identifies primary actions, active + navigation, focus, and important values. It should not cover large areas. +4. **State is always visible.** Loading, disabled, active, archived, configured, + error, and success states need both visual and textual indicators. +5. **Square and engineered.** Panels and controls use square corners, fine + borders, tight spacing, and deliberate grid alignment. +6. **Links use interface feedback, not underlines.** Links do not use native + text underlines. Communicate hover and focus through SHD accent color, + borders, brightness, and the required focus-visible outline. ## Brand identity @@ -48,18 +53,18 @@ text. Small-size artwork must retain both colors, widen the arc gaps where the pixel grid requires it, and keep the packet and open core distinct. Treat the emblem as decorative when visible `SHD.build` text names the product. -Give an emblem-only home link the accessible name `SHD.build home`. Preserve -the approved colors and full opacity in focus, disabled, and compact contexts; -use text or control styling to communicate state instead of fading the mark. -The Field Manual White core and Signal Orange arcs must remain distinct from -their background at every delivered size. +Give an emblem-only home link the accessible name `SHD.build home`. Preserve the +approved colors and full opacity in focus, disabled, and compact contexts; use +text or control styling to communicate state instead of fading the mark. The +Field Manual White core and Signal Orange arcs must remain distinct from their +background at every delivered size. ### Routed error console Expected HTTP errors and unexpected server faults use one shared SHD exception -console inside the application shell. The console displays the -numeric status, an operational title, a safe explanation, request path, node, -and context-appropriate recovery controls. Common statuses receive distinct +console inside the application shell. The console displays the numeric status, +an operational title, a safe explanation, request path, node, and +context-appropriate recovery controls. Common statuses receive distinct language: authentication for 401, clearance for 403, lost location for 404, conflict for 409, saturation for 429, and service/fault language for 5xx. @@ -68,9 +73,9 @@ faults. Do not display stack traces, database messages, credentials, request queries, or raw unexpected exceptions. The global error handler gives each unexpected server fault a short support reference ID and keeps the full error server-side. If the root application shell cannot render, `src/error.html` -provides a dependency-free SHD fallback surface. -Error controls retain square geometry, focus-visible treatment, pressed motion, -plain-language labels, and stacked mobile recovery actions. +provides a dependency-free SHD fallback surface. Error controls retain square +geometry, focus-visible treatment, pressed motion, plain-language labels, and +stacked mobile recovery actions. ### Contact relay and legal requests @@ -82,25 +87,24 @@ uses a distinct provider-colored card alongside the durable contact channel. The Contact hero resets the global site-header height: semantic page `header` elements use content-driven height and spacing instead of the 76-pixel -navigation-shell constraint. Its title, description, and Discord -card align within a minimum 230-pixel desktop band before the form grid begins. -The Contact page itself remains content-height-driven so high-resolution browser -zoom does not multiply a viewport minimum into a large empty region above the -footer. +navigation-shell constraint. Its title, description, and Discord card align +within a minimum 230-pixel desktop band before the form grid begins. The Contact +page itself remains content-height-driven so high-resolution browser zoom does +not multiply a viewport minimum into a large empty region above the footer. The application frame uses a full-viewport vertical flex stack. The shared footer uses automatic top margin. It rests against the bottom edge on short routes and follows document flow after longer content. Do not add spacer heights -only to position the footer. The frame compensates for -`--shd-display-scale` when calculating its viewport minimum so high-resolution -browser zoom does not push a short-page footer below the visible edge. +only to position the footer. The frame compensates for `--shd-display-scale` +when calculating its viewport minimum so high-resolution browser zoom does not +push a short-page footer below the visible edge. -Community access in the shared footer uses a compact Discord-branded action -with the official symbol, provider blue surface, explicit external-link text, -and visible hover, focus, and pressed feedback. Keep provider branding separate -from the orange links reserved for SHD navigation and legal resources. Support -and legal links form a compact horizontal rail that may wrap at intermediate -widths and becomes a two-column touch-friendly grid on narrow mobile screens. +Community access in the shared footer uses a compact Discord-branded action with +the official symbol, provider blue surface, explicit external-link text, and +visible hover, focus, and pressed feedback. Keep provider branding separate from +the orange links reserved for SHD navigation and legal resources. Support and +legal links form a compact horizontal rail that may wrap at intermediate widths +and becomes a two-column touch-friendly grid on narrow mobile screens. The footer repeats the complete Core Segments lockup used by the global header: two-color emblem, Field Manual White `SHD`, Signal Orange `.build`, and the @@ -111,24 +115,23 @@ drop either emblem color, or color the complete product name orange. SHD.build provides Division Resurgence data and tools for players. Primary product language and global navigation represent five durable channels: News, -Database, Builds, Guides, and Builder. Loadout-specific terms remain -appropriate inside build routes. The global brand descriptor, homepage -metadata, hero, sign-in introduction, and footer describe the broader toolset. +Database, Builds, Guides, and Builder. Loadout-specific terms remain appropriate +inside build routes. The global brand descriptor, homepage metadata, hero, +sign-in introduction, and footer describe the broader toolset. The homepage uses a community-channel grid to establish this information -architecture. Each channel has an operational code, plain-language purpose, -and explicit destination. New channels should extend this reusable pattern -rather than turning the navigation into an undifferentiated list. Honest -launch-state pages may describe planned capability, but must distinguish queued -features from live data. Database landing metrics use approved active records, -not invented activity counts. +architecture. Each channel has an operational code, plain-language purpose, and +explicit destination. New channels should extend this reusable pattern rather +than turning the navigation into an undifferentiated list. Honest launch-state +pages may describe planned capability, but must distinguish queued features from +live data. Database landing metrics use approved active records, not invented +activity counts. -On desktop the five public destinations remain visible in the global -navigation. Mobile navigation numbers destinations dynamically so adding a -channel cannot collide with Inbox, Profile, or ISAC codes. Community channel -cards collapse from four columns to two and then one; hover color alone never -replaces their textual destination labels, focus-visible treatment, or pressed -feedback. +On desktop the five public destinations remain visible in the global navigation. +Mobile navigation numbers destinations dynamically so adding a channel cannot +collide with Inbox, Profile, or ISAC codes. Community channel cards collapse +from four columns to two and then one; hover color alone never replaces their +textual destination labels, focus-visible treatment, or pressed feedback. Authenticated desktop account destinations are consolidated into one agent-status menu at the right edge of the global header. Its trigger combines @@ -140,14 +143,14 @@ header, with orange reserved for the open/current state and unread count and danger color reserved for the Sign Out hover state. The account trigger exposes its expanded and controlled-menu state. Opening the -menu moves keyboard focus to its first destination; Escape closes it and -returns focus to the trigger, while an outside click dismisses it. Mobile keeps -Profile, Notifications, and Sign Out as explicit navigation actions and shows -the avatar in the signed-in identity block. Avatar imagery supplements rather -than replaces the textual identity, state, and unread indicators. User avatars -are always rendered as circular portraits with the restrained orange SHD ring -used by the Agent Profile; the source image remains square and uses -`object-fit: cover` so authenticated surfaces crop it consistently. +menu moves keyboard focus to its first destination; Escape closes it and returns +focus to the trigger, while an outside click dismisses it. Mobile keeps Profile, +Notifications, and Sign Out as explicit navigation actions and shows the avatar +in the signed-in identity block. Avatar imagery supplements rather than replaces +the textual identity, state, and unread indicators. User avatars are always +rendered as circular portraits with the restrained orange SHD ring used by the +Agent Profile; the source image remains square and uses `object-fit: cover` so +authenticated surfaces crop it consistently. When unread notifications exist on mobile, repeat the compact orange count on both the menu trigger and the Notifications destination. The trigger badge sits @@ -156,21 +159,21 @@ Its accessible name includes the full unread count; the visible badge is hidden from assistive technology to avoid announcing the count twice. Oversight reviewers receive a permission-aware cadence console inside -Notification Preferences. Each available review queue is shown independently -and uses the shared segmented radio pattern for Immediate, 15-minute, one-hour, -or daily delivery. Do not expose categories the agent cannot review. -Consolidated delivery groups queue items rather than issuing repeated messages; -daily delivery targets 10:00 AM in `America/New_York` so the Eastern wall-clock -time remains stable across daylight-saving transitions. On mobile, cadence -segments wrap to two columns and each review category becomes its own row. +Notification Preferences. Each available review queue is shown independently and +uses the shared segmented radio pattern for Immediate, 15-minute, one-hour, or +daily delivery. Do not expose categories the agent cannot review. Consolidated +delivery groups queue items rather than issuing repeated messages; daily +delivery targets 10:00 AM in `America/New_York` so the Eastern wall-clock time +remains stable across daylight-saving transitions. On mobile, cadence segments +wrap to two columns and each review category becomes its own row. -ISAC System Configuration is a distinct navigation domain from System -Oversight. It contains operational configuration such as Automated Messages and -read-only observability such as System Metrics. Automated-message editors use -the standard horizontal edit lifecycle: successful edits close the editor, -validation preserves it, and event triggers remain explicit. Content, enabled -state, post-event delay, primary destination, and up to eight action callouts -are displayed as structured fields rather than raw JSON. +ISAC System Configuration is a distinct navigation domain from System Oversight. +It contains operational configuration such as Automated Messages and read-only +observability such as System Metrics. Automated-message editors use the standard +horizontal edit lifecycle: successful edits close the editor, validation +preserves it, and event triggers remain explicit. Content, enabled state, +post-event delay, primary destination, and up to eight action callouts are +displayed as structured fields rather than raw JSON. Automated ISAC messages may expose multiple square action callouts in the notification inbox and detail view. Callouts wrap on desktop and become @@ -204,9 +207,9 @@ behavior. When an equipment level changes, preserve the configured roll's relative position within its previous range instead of resetting it arbitrarily. Mark -unverified portions of a level curve with both `ESTIMATED` text and the -High-end yellow caution treatment. Controls stack the slider and direct input -at narrow mobile widths and must never create horizontal page overflow. +unverified portions of a level curve with both `ESTIMATED` text and the High-end +yellow caution treatment. Controls stack the slider and direct input at narrow +mobile widths and must never create horizontal page overflow. Equipment level uses this same synchronized control rather than a standalone number field. Changing level remaps base and rolled values to the equivalent @@ -216,8 +219,8 @@ Gear and weapons always expose exactly two required rolled-Attribute positions. Render both positions as persistent numbered rows; do not offer add or remove actions. Already selected Attributes are disabled in the other position. -Use compact segmented radio controls for the small finite weapon-tier set. -Use the shared builder radio grid for visually identified Brand Sets and weapon +Use compact segmented radio controls for the small finite weapon-tier set. Use +the shared builder radio grid for visually identified Brand Sets and weapon talents: transparent icons lead each square engineered option, text remains visible, and the selected state uses the orange action edge plus an explicit radio state. Talent grids become searchable when their available pool is large. @@ -228,31 +231,35 @@ labels and 44-pixel-or-larger targets remain usable. The canonical tokens live in `src/app.css` under `:root`. -| Purpose | Token | Value | -| --- | --- | --- | -| Page background | `--shd-bg` | `#0a0c0d` | -| Base surface | `--shd-surface` | `#111415` | -| Raised surface | `--shd-surface-raised` | `#141718` | -| Modeled-data surface | `--shd-model-surface` | `#0d1213` | -| Subordinate border | `--shd-border-soft` | `#272c2d` | -| Primary border | `--shd-border` | `#303536` | -| Modeled-data border | `--shd-model-border` | `#3b4648` | -| Interactive boundary | `--shd-border-strong` | `#424849` | -| Main text | `--shd-text` | `#ecebe5` | -| Muted text | `--shd-text-muted` | `#858b8d` | -| SHD action orange | `--shd-orange` | `#f6a821` | -| Telemetry text floor | `--shd-type-telemetry` | `12px` | -| Supporting copy floor | `--shd-type-supporting` | `14px` | -| Ordinary body floor | `--shd-type-body` | `16px` | -| Adaptive display scale | `--shd-display-scale` | `1` by default; stepped on high-resolution desktops | -| Monospaced data | `--shd-font-mono` | Cascadia Mono with system monospace fallbacks | +| Purpose | Token | Value | +| ---------------------- | ----------------------- | --------------------------------------------------- | +| Page background | `--shd-bg` | `#0a0c0d` | +| Base surface | `--shd-surface` | `#111415` | +| Raised surface | `--shd-surface-raised` | `#141718` | +| Modeled-data surface | `--shd-model-surface` | `#0d1213` | +| Subordinate border | `--shd-border-soft` | `#272c2d` | +| Primary border | `--shd-border` | `#303536` | +| Modeled-data border | `--shd-model-border` | `#3b4648` | +| Interactive boundary | `--shd-border-strong` | `#424849` | +| Main text | `--shd-text` | `#ecebe5` | +| Muted text | `--shd-text-muted` | `#858b8d` | +| SHD action orange | `--shd-orange` | `#f6a821` | +| Armor surface | `--shd-armor-surface` | `#13211f` | +| Armor border | `--shd-armor-border` | `#71aaa5` | +| Armor label | `--shd-armor-label` | `#8eb9b4` | +| Armor text | `--shd-armor-text` | `#a5d1ca` | +| Telemetry text floor | `--shd-type-telemetry` | `12px` | +| Supporting copy floor | `--shd-type-supporting` | `14px` | +| Ordinary body floor | `--shd-type-body` | `16px` | +| Adaptive display scale | `--shd-display-scale` | `1` by default; stepped on high-resolution desktops | +| Monospaced data | `--shd-font-mono` | Cascadia Mono with system monospace fallbacks | ### High-resolution display scaling -The application uses restrained stepped interface scaling on wide CSS -viewports. At 2200px, the interface scales to 115%; at 2800px, to 130%; and at -3400px, to 150%. This maintains the established relationship among typography, -controls, tables, spacing, hit targets, and fixed-position surfaces. +The application uses restrained stepped interface scaling on wide CSS viewports. +At 2200px, the interface scales to 115%; at 2800px, to 130%; and at 3400px, to +150%. This maintains the established relationship among typography, controls, +tables, spacing, hit targets, and fixed-position surfaces. Use the shared `--shd-display-scale` token and the root-level `zoom` treatment; do not add module-specific 4K font-size overrides. Responsive layouts below @@ -260,12 +267,12 @@ do not add module-specific 4K font-size overrides. Responsive layouts below remain available to users and naturally change the effective CSS viewport. High-resolution scaling must not introduce horizontal overflow, clipped fixed surfaces, or a different semantic hierarchy. Test the standard desktop layout -and each affected wide breakpoint after changing this policy. -| Success | `--shd-success` | `#75c08c` | -| Destructive/error | `--shd-danger` | `#df8069` | +and each affected wide breakpoint after changing this policy. | Success | +`--shd-success` | `#75c08c` | | Destructive/error | `--shd-danger` | `#df8069` | | Informational/defensive | `--shd-info` | `#70b9d6` | -Use the spacing tokens for new components. Prefer `8px`, `12px`, and `20px` gaps. Dashboard controls use the shared `40px` height when practical. +Use the spacing tokens for new components. Prefer `8px`, `12px`, and `20px` +gaps. Dashboard controls use the shared `40px` height when practical. ## Typography @@ -275,20 +282,19 @@ SHD.build self-hosts two type voices through Fontsource packages: controls, data, labels, metadata, and general interface copy. Use the shared `--shd-font-sans` token instead of declaring local sans-serif stacks. - **Source Serif 4** is the editorial family for explanations, introductions, - guide-like prose, and longer descriptive passages. Use - `--shd-font-editorial`; do not apply it to controls, tables, metrics, or - telemetry labels. + guide-like prose, and longer descriptive passages. Use `--shd-font-editorial`; + do not apply it to controls, tables, metrics, or telemetry labels. -Load Barlow weights 400, 600, 700, 800, and 900, plus Source Serif 4 weights -400 and 600. Both families are self-hosted, keep fallback stacks, and render -without a third-party font request. Barlow operates the system; Source Serif 4 -explains it. +Load Barlow weights 400, 600, 700, 800, and 900, plus Source Serif 4 weights 400 +and 600. Both families are self-hosted, keep fallback stacks, and render without +a third-party font request. Barlow operates the system; Source Serif 4 explains +it. - Page and module headings are uppercase, heavy, and tightly spaced. - Short operational labels, metadata, statuses, and compact actions use the shared 12px telemetry floor. Their tracking must not exceed `0.1em`. -- Supporting descriptions use at least 14px with normal tracking. Ordinary - body and form copy uses the 16px body floor. +- Supporting descriptions use at least 14px with normal tracking. Ordinary body + and form copy uses the 16px body floor. - High-resolution workspace scaling never justifies smaller source sizes: density comes from spacing, connected borders, tone, and weight. - Public/editorial copy uses Source Serif 4 for its restrained field-manual @@ -300,8 +306,8 @@ explains it. ### Global mobile navigation At viewports up to 900px, replace the hidden desktop link rail and account -controls with a labeled square `MENU` control in the shared site -header. Activating it opens a right-side SHD navigation panel over a dismissible +controls with a labeled square `MENU` control in the shared site header. +Activating it opens a right-side SHD navigation panel over a dismissible backdrop. The panel includes Home, each enabled public destination, Sign In or My Profile, and ISAC when authorized. Authenticated agents also receive identity state and Sign Out inside the panel. Section availability controls both desktop @@ -312,8 +318,8 @@ orange action indicators, and minimum 44px interaction targets. Opening moves keyboard focus to the first route. Escape, backdrop activation, route changes, and the header Close control dismiss the panel. The toggle exposes `aria-expanded` and `aria-controls`, the panel has a distinct navigation label, -and reduced-motion preferences remove icon transitions. Do not make mobile -users rely on the logo or desktop-only account controls to reach core routes. +and reduced-motion preferences remove icon transitions. Do not make mobile users +rely on the logo or desktop-only account controls to reach core routes. On phone-sized viewports up to 700px, ISAC module toolbars stack their action area beneath the module title. A single Add action spans the available width; @@ -335,45 +341,45 @@ squeezed beside long module headings on mobile. ### Command Grid builder The production builder uses Command Grid as its loadout workbench. Keep the -entire build visible in a dense matrix while one connected editor configures -the selected cell. The full-width Specialization and Focus cell is the root -choice. Its three supplied abilities appear directly below it, followed by two -weapons, six gear positions, and one full-width OS Protocol. +entire build visible in a dense matrix while one connected editor configures the +selected cell. The full-width Specialization and Focus cell is the root choice. +Its three supplied abilities appear directly below it, followed by two weapons, +six gear positions, and one full-width OS Protocol. -Matrix cells summarize selected Talent names, Attribute names and values, -Brand Sets, or Skill Chips. Do not replace those choices with configuration -counts. Selecting an ability opens its three attached Skill Chip positions. -Selecting equipment opens the searchable real-data catalog and its compatible +Matrix cells summarize selected Talent names, Attribute names and values, Brand +Sets, or Skill Chips. Do not replace those choices with configuration counts. +Selecting an ability opens its three attached Skill Chip positions. Selecting +equipment opens the searchable real-data catalog and its compatible configuration controls. Do not display a prototype option that the current loadout schema cannot save. Calculation readiness belongs at the point of resolution. Mark an incomplete -matrix cell with a compact red text indicator and status rail, then let the -cell open the editor that resolves it. The live calculation rail summarizes -blocked output in one compact row. Do not repeat every missing slot in a large -error panel. Keep system-owned blockers, such as an unpublished calculation -profile, visible in that compact summary because the player cannot resolve -them from a loadout cell. +matrix cell with a compact red text indicator and status rail, then let the cell +open the editor that resolves it. The live calculation rail summarizes blocked +output in one compact row. Do not repeat every missing slot in a large error +panel. Keep system-owned blockers, such as an unpublished calculation profile, +visible in that compact summary because the player cannot resolve them from a +loadout cell. -The builder uses three workbench columns on wide screens, moves the live -summary below the matrix and editor at intermediate widths, and stacks all -three regions on narrow screens. Every grid track and editor child must accept -content shrinkage. At 560px and below, stack attribute picker/value pairs and -section headings; keep the command actions in two equal columns. Validation -markers use short visible labels while their full messages remain available -to assistive technology and pointer users. +The builder uses three workbench columns on wide screens, moves the live summary +below the matrix and editor at intermediate widths, and stacks all three regions +on narrow screens. Every grid track and editor child must accept content +shrinkage. At 560px and below, stack attribute picker/value pairs and section +headings; keep the command actions in two equal columns. Validation markers use +short visible labels while their full messages remain available to assistive +technology and pointer users. Use `--shd-builder-canvas` for recessed equipment fields. Use -`--shd-builder-selection` with `--shd-builder-selection-border` for the -selected cell. Preserve button semantics, `aria-pressed`, focus-visible -outlines, pressed feedback, and textual readiness states. At narrower desktop -widths, keep the matrix and editor together and move live results below them. -On mobile, stack the matrix, editor, and live results in that order. +`--shd-builder-selection` with `--shd-builder-selection-border` for the selected +cell. Preserve button semantics, `aria-pressed`, focus-visible outlines, pressed +feedback, and textual readiness states. At narrower desktop widths, keep the +matrix and editor together and move live results below them. On mobile, stack +the matrix, editor, and live results in that order. The command header contains the required build name, local and account save -actions, account visibility, and optional sharing notes. Sharing notes include -a short strengths summary and a Markdown usage guide. Persist them separately -from the validated loadout payload and render them on the shared build page. +actions, account visibility, and optional sharing notes. Sharing notes include a +short strengths summary and a Markdown usage guide. Persist them separately from +the validated loadout payload and render them on the shared build page. ### Prelaunch network shell @@ -414,14 +420,14 @@ status while closed, show a focus-visible outline, and provide at least a closed so draft form state survives expansion changes. Disable motion under `prefers-reduced-motion`. -Each command panel owns its save action, validation feedback, pending state, -and success notice. Do not place a page-wide review or save banner between -panels. Disable a panel's save button until that panel differs from saved -state. Saving one panel must update only its fields, retain the panel's open -state, and leave unsaved drafts in other panels unchanged. Keep `PREVIEW PAGE` -inside the Launch Gate save row so authenticated operators can inspect the -promotional page without ending their session. Operators need text for enabled, -disabled, coming-soon, and public states; color supports those labels. +Each command panel owns its save action, validation feedback, pending state, and +success notice. Do not place a page-wide review or save banner between panels. +Disable a panel's save button until that panel differs from saved state. Saving +one panel must update only its fields, retain the panel's open state, and leave +unsaved drafts in other panels unchanged. Keep `PREVIEW PAGE` inside the Launch +Gate save row so authenticated operators can inspect the promotional page +without ending their session. Operators need text for enabled, disabled, +coming-soon, and public states; color supports those labels. The Launch Gate controls signed-out entry to the application. Public Section controls apply to everyone. When an operator disables News, Database, Builds, @@ -440,12 +446,12 @@ direct URL. Do not present authentication as a way around a disabled section. create a separate profile-editing dashboard or remove community context while the owner manages their profile. - Dense account consoles use a tabbed control surface for profile, credentials, - linked identities, and account lifecycle actions. Only one panel is shown at - a time; validation failures reopen the panel containing the failed action. + linked identities, and account lifecycle actions. Only one panel is shown at a + time; validation failures reopen the panel containing the failed action. Closely related mutually exclusive workflows, such as selecting a preset - portrait or uploading a custom portrait, use a compact secondary tab row. - Tabs retain native button semantics plus tablist, tab, and tabpanel roles, - textual selected state, keyboard focus, and pressed feedback. + portrait or uploading a custom portrait, use a compact secondary tab row. Tabs + retain native button semantics plus tablist, tab, and tabpanel roles, textual + selected state, keyboard focus, and pressed feedback. - Account Access uses a secondary method navigation for passkeys, verified email, local password fallback, and linked identity providers. Each tab shows a compact configured-method count and routes failed actions back to the @@ -461,10 +467,10 @@ direct URL. Do not present authentication as a way around a disabled section. red and replace the label with `UNLINK`; activation replaces the full card surface with an explicit Cancel / Confirm Unlink decision. Confirmation uses the square SHD primary action with the orange action accent, while Cancel - remains a dark outlined secondary control. The authenticated - session plus this deliberate second action authorizes the request; the shared - server-side last-method guard remains authoritative. Future provider cards - reuse this pattern rather than adding provider-specific credential prompts. + remains a dark outlined secondary control. The authenticated session plus this + deliberate second action authorizes the request; the shared server-side + last-method guard remains authoritative. Future provider cards reuse this + pattern rather than adding provider-specific credential prompts. - Authentication pages use a split frame: contextual SHD network information on the left and the actionable secure-access console on the right. - OAuth providers appear as connection nodes with a recognizable provider color; @@ -474,9 +480,9 @@ direct URL. Do not present authentication as a way around a disabled section. explicitly labeled optional fallback in a collapsed control. Passkey controls name the platform interaction rather than exposing WebAuthn terminology. - Verified email identities and registered passkeys appear as compact method - rows in Account Access. Every method displays its verification or device state, - registration date where useful, and protected removal behavior that prevents - an agent from deleting their last usable sign-in method. + rows in Account Access. Every method displays its verification or device + state, registration date where useful, and protected removal behavior that + prevents an agent from deleting their last usable sign-in method. - Local username and password are one optional authentication method, separate from the public display name and the immutable internal account identifier. Agents may remove both local credentials together after adding another method, @@ -513,19 +519,18 @@ shared `AgentIdentityLink` in its large state: circular portrait, display name, and assigned role badges. Place role badges to the right of the display name on desktop and below the name on mobile. Show role names only, in alphabetical order, and omit the list when the Agent has no assigned roles. Badges use the -shared raised surface, explicit role text, and a small orange -diamond; they wrap without truncating long or multiple role names. Never expose -role descriptions, permissions, direct grants, or access scopes on public -profiles. The owner header contains the visibility switch, its information -control, and a mobile-only Account Control link. Present the primary identity -and its actions directly on the page canvas -without a card surface, border, or decorative accent rule. Give the identity and -actions content-sized responsive tracks so long names, multiple roles, and -mobile actions cannot overlap the public content totals. Keep those totals in a -compact telemetry strip below the identity header. Use four columns on desktop -and two columns on mobile. Place the field bio in the primary column and keep -its mobile Edit Bio action compact within the section header. Put Public Builds -and Published Guides side by side directly +shared raised surface, explicit role text, and a small orange diamond; they wrap +without truncating long or multiple role names. Never expose role descriptions, +permissions, direct grants, or access scopes on public profiles. The owner +header contains the visibility switch, its information control, and a +mobile-only Account Control link. Present the primary identity and its actions +directly on the page canvas without a card surface, border, or decorative accent +rule. Give the identity and actions content-sized responsive tracks so long +names, multiple roles, and mobile actions cannot overlap the public content +totals. Keep those totals in a compact telemetry strip below the identity +header. Use four columns on desktop and two columns on mobile. Place the field +bio in the primary column and keep its mobile Edit Bio action compact within the +section header. Put Public Builds and Published Guides side by side directly beneath the bio; stack them on narrow screens. Their cards stay within their own half of the primary column. Profile Messages follow the archive pair within the same column and must not inherit height from the right rail. @@ -533,15 +538,13 @@ same column and must not inherit height from the right rail. Recent Discussion occupies the narrower activity rail. Owner-only Account Control and notification-preview widgets appear above it. The profile preview shows unread notifications only; the full inbox retains notification history. -Recent-discussion -entries use compact destination cards because each entry links to content -elsewhere on the site. Show only the discussion type, destination title, comment -date, and open action; do not repeat the message body in this rail. Reset nested -card headers to content height and align the destination identity with its date; -do not inherit the application header's fixed height or surface treatment. -Profile messages continue directly -beneath the biography in the primary column so the conversation begins without -dead space. +Recent-discussion entries use compact destination cards because each entry links +to content elsewhere on the site. Show only the discussion type, destination +title, comment date, and open action; do not repeat the message body in this +rail. Reset nested card headers to content height and align the destination +identity with its date; do not inherit the application header's fixed height or +surface treatment. Profile messages continue directly beneath the biography in +the primary column so the conversation begins without dead space. Use `AgentIdentityLink` for author attribution on builds, guides, comments, and other community records. Its compact and default states combine the circular @@ -569,40 +572,75 @@ quotes, links, bold, italics, and inline code. Load the required editorial font weights and italic face because the global type system disables synthetic font styles. Raw HTML is escaped. +Guides and News use the shared structured article composer rather than the +compact Markdown editor. The composer treats the article as an editorial work +surface. The full-width title and deck match the published lede, while the +readable canvas stays near 76 characters. A publication bar above the canvas +groups visibility, News channel, publication state, and the primary action. The +sticky right inspector contains cover media, Guide tags, moderation context, and +deletion without narrowing the headline preview. The sticky toolbar uses native +buttons for paragraph, H2, H3, bold, italic, inline code, links, lists, quotes, +dividers, images, Build embeds, undo, and redo. On narrow screens, the 44-pixel +toolbar scrolls horizontally, followed by the publication bar and inspector in +document order. + +Persist the versioned Tiptap JSON document, never generated editor HTML. The +server accepts only documented nodes, marks, attributes, HTTP or HTTPS links, +bounded depth, and bounded text. `ArticleContent` renders validated nodes with +Svelte elements. Inline article images require alternative text and may include +a caption. The reading page uses Source Serif 4 for prose and Barlow for +headings, metadata, controls, and embedded operational data. + +`BuildEmbed` is the reusable article representation for a saved Build. A Guide +may contain several Build blocks, and a Build may appear in several Guides. Each +published revision stores the stable Build ID and a bounded display snapshot. +The component renders a connected weapon and gear matrix instead of an +interactive Builder. An author-owned private or unlisted Build displays as +`AUTHOR-SHARED`; the Guide makes that snapshot public without changing the Build +page visibility. A Build owned by another Agent displays only while the source +remains public. + +Guide discovery separates three Community Oversight decisions. Pins form an +ordered group above the default feed, Featured Guides occupy a dedicated +editorial group, and Recommended Guides carry a textual badge and filter. Search +relevance is not overridden by curation. Guide cards pair a cover or +field-manual fallback with title, deck, author, publication date, tags, and the +rating count. Unlisted and private articles never enter discovery or search. + Profile conversations begin with a collapsed `LEAVE A MESSAGE` action. A reply opens the same editor beneath the selected message, and only one editor remains open. Comments sit on the page canvas without cards, section surfaces, or detached author headers. Align each body and action row with the username rather than the avatar gutter. Keep Reply visible below the message. Place Follow or Unfollow, Report, and the author-only Remove action in a shared ellipsis menu -beside Reply. The menu uses a 44-pixel trigger, returns focus when -closed, supports Escape and directional-key navigation, and becomes a fixed -bottom panel on narrow screens. Keep destructive actions neutral at rest and use -the danger color on hover or keyboard focus. Render conversations -in depth-first reading order: newest root conversations first and each branch -oldest-first. Use one-pixel guide lines and restrained indentation for replies, -then cap visual indentation on narrow screens while retaining the logical -parent chain. If an author or moderator removes a message that has replies, show -a bodyless `MESSAGE REMOVED` placeholder so readers can follow the remaining -conversation. Following applies to the top-level conversation and sends inbox -notifications for visible descendant replies, excluding the reply author and -any recipient already receiving the direct-reply notification. Send reply -notifications to the direct parent author and link to the specific reply. -Recent-discussion links target the exact comment anchor instead of the -containing profile or content page. +beside Reply. The menu uses a 44-pixel trigger, returns focus when closed, +supports Escape and directional-key navigation, and becomes a fixed bottom panel +on narrow screens. Keep destructive actions neutral at rest and use the danger +color on hover or keyboard focus. Render conversations in depth-first reading +order: newest root conversations first and each branch oldest-first. Use +one-pixel guide lines and restrained indentation for replies, then cap visual +indentation on narrow screens while retaining the logical parent chain. If an +author or moderator removes a message that has replies, show a bodyless +`MESSAGE REMOVED` placeholder so readers can follow the remaining conversation. +Following applies to the top-level conversation and sends inbox notifications +for visible descendant replies, excluding the reply author and any recipient +already receiving the direct-reply notification. Send reply notifications to the +direct parent author and link to the specific reply. Recent-discussion links +target the exact comment anchor instead of the containing profile or content +page. Public user-generated content uses the shared `ContentReport` control when the viewer is authenticated and does not own the content. Keep the control visually secondary to reading and reply actions. Its disclosure names the concern, accepts optional bounded context, and states that a report requests human review -without removing content immediately. On narrow screens, present the report -form as a fixed bottom panel with full-width touch controls. +without removing content immediately. On narrow screens, present the report form +as a fixed bottom panel with full-width touch controls. -Submission forms for public user-generated text and custom images use the -shared `ModerationDisclosure` notice. Place it after the input and before -validation or submission controls. The always-visible notice states that -automated safety review may occur and links to the Terms & Privacy section for -the details. Do not hide this disclosure in a hover-only tooltip. +Submission forms for public user-generated text and custom images use the shared +`ModerationDisclosure` notice. Place it after the input and before validation or +submission controls. The always-visible notice states that automated safety +review may occur and links to the Terms & Privacy section for the details. Do +not hide this disclosure in a hover-only tooltip. Automated moderation is exception-based. Clean content publishes without an Oversight task when policy permits. Flagged comments remain hidden with an @@ -619,12 +657,11 @@ Keep it inline because operators need the registry for context. Show exact-word behavior beside the textarea, preserve pasted values after validation errors, and clear the textarea while keeping the editor open after a successful import. Search the registry on the server across term text, normalized text, rule type, -match mode, and operational note. Use the shared pagination rail with -namespaced URL parameters and 25 rows by default. Search applies before -pagination. Distinguish an empty registry from a search with no matches. The -registry toolbar, editors, search, and pagination stay inside the term -disclosure. -Only HTTP and HTTPS links are active; rendered links open separately with UGC, +match mode, and operational note. Use the shared pagination rail with namespaced +URL parameters and 25 rows by default. Search applies before pagination. +Distinguish an empty registry from a search with no matches. The registry +toolbar, editors, search, and pagination stay inside the term disclosure. Only +HTTP and HTTPS links are active; rendered links open separately with UGC, nofollow, and opener-isolation attributes. `MarkdownContent` must receive HTML from the server renderer, never raw user input or client-rendered Markdown. @@ -644,11 +681,11 @@ consequence in an adjacent information popover. Display-name restrictions use the same popover pattern instead of persistent instructional copy. Community forms use `PendingForm` to block duplicate submissions. Pending -actions retain their label context, validation errors preserve entered text, -and success or error feedback appears beside the affected form. All editor, -link, and button controls require the shared orange focus-visible treatment. -Message removal is a two-step, in-card confirmation: the first action reveals a -red danger panel, then separate Cancel and Confirm Remove actions complete or +actions retain their label context, validation errors preserve entered text, and +success or error feedback appears beside the affected form. All editor, link, +and button controls require the shared orange focus-visible treatment. Message +removal is a two-step, in-card confirmation: the first action reveals a red +danger panel, then separate Cancel and Confirm Remove actions complete or abandon the request. Only an authorized author receives the removal control. Below 1000px, move profile communications beneath the primary content and stop @@ -662,7 +699,8 @@ error, or danger indicators. ### Third-party identity buttons - Follow the identity provider's current official brand and sign-in guidelines. -- Use official, unmodified assets downloaded from the provider's brand resources. +- Use official, unmodified assets downloaded from the provider's brand + resources. - Discord sign-in uses the official white Discord symbol on Blurple `#5865F2` with the explicit label “Continue with Discord.” - Discord account connection controls use the same official symbol and Blurple @@ -683,9 +721,9 @@ error, or danger indicators. - Preserve the provider's required clearspace, logo proportions, approved color, readable contrast, and text/logo alignment even when the surrounding surface follows the SHD theme. -- Provider buttons still require SHD.build's focus-visible, pressed, pending, and - duplicate-interaction protection where those states do not conflict with the - provider's branding requirements. +- Provider buttons still require SHD.build's focus-visible, pressed, pending, + and duplicate-interaction protection where those states do not conflict with + the provider's branding requirements. - Authentication uses one unified access screen rather than separate sign-in and registration destinations. Verified email and first-time OAuth identities may create an account; returning identities sign into the matching account. @@ -697,9 +735,9 @@ error, or danger indicators. - Password access uses an explicit `SIGN IN` / `CREATE ACCOUNT` mode switch inside the fallback panel. Account creation separates the public display name from the private ASCII login username. -- Passkeys are a returning-user method on this screen. New agents first establish - an account through verified email, OAuth, or local credentials, then enroll a - passkey from Account Access. +- Passkeys are a returning-user method on this screen. New agents first + establish an account through verified email, OAuth, or local credentials, then + enroll a passkey from Account Access. ### Footer and legal surfaces @@ -710,11 +748,13 @@ error, or danger indicators. - Login security metadata is operationally visible in ISAC for 30 days. After that period, IP, user-agent, normalized client/device fields, and credential references and proxy-supplied approximate location are scrubbed while - non-sensitive event facts, categorical risk flags, and session outcomes may remain. + non-sensitive event facts, categorical risk flags, and session outcomes may + remain. ### Admin dashboard -- Use `admin-page`, `admin-command`, `admin-dashboard`, `admin-sidebar`, and `admin-content`. +- Use `admin-page`, `admin-command`, `admin-dashboard`, `admin-sidebar`, and + `admin-content`. - The administrative system is named ISAC. Separate navigation into explicit operational domains such as User Operations and Data Management; visibility must reflect server-provided capabilities. @@ -738,9 +778,9 @@ error, or danger indicators. - ISAC authentication-method removal is always a two-step interaction contained within the affected method row. The confirmation state explains the impact and provides explicit Cancel and orange Confirm actions before submission. -- Agent dossiers consolidate operational controls into full-width tabs for - Roles & Permissions, Authentication, and Moderation. Keep dense controls in - their relevant tab instead of allowing one dashboard column to grow far beyond +- Agent dossiers consolidate operational controls into full-width tabs for Roles + & Permissions, Authentication, and Moderation. Keep dense controls in their + relevant tab instead of allowing one dashboard column to grow far beyond adjacent content. - Avatar removal uses the same in-card, two-step confirmation interaction as authentication-method removal. @@ -756,76 +796,76 @@ error, or danger indicators. so changing one history page does not reset the others. - Each module starts with `module-toolbar`, followed by `module-kpis`. - Editors use `dashboard-editor`. New-record editors appear before the affected - table. Editing an existing record expands the same editor directly beneath - its selected row, which gains an orange selection rail and an explicit - expanded relationship for assistive technology. Inline editors stay aligned - to the visible registry viewport when the table scrolls horizontally. A - short clipped reveal confirms the expansion; reduced-motion preferences - remove that animation. + table. Editing an existing record expands the same editor directly beneath its + selected row, which gains an orange selection rail and an explicit expanded + relationship for assistive technology. Inline editors stay aligned to the + visible registry viewport when the table scrolls horizontally. A short clipped + reveal confirms the expansion; reduced-motion preferences remove that + animation. - Optional record descriptions use a full-width narrative field beneath the identity controls. Preserve the same full-width placement in Oversight before/after previews so reviewers can compare meaning without reading raw proposal data. - Data collections use `dashboard-table`, `dashboard-row`, and `table-heading`. -- Keep tables dense and horizontally scrollable rather than wrapping columns unpredictably. +- Keep tables dense and horizontally scrollable rather than wrapping columns + unpredictably. - User dossiers pair access controls and moderation in a two-column console, followed by compact login and content-history streams. Restriction state must be textual and use the danger color; inactivity remains a separate activity classification rather than a punishment state. - Role names describe durable organizational responsibility. `Admin` is the unrestricted administrative role; `Community Oversight` is reserved for - community content moderation and does not imply ISAC access. Operational - ISAC access is delegated separately through scoped roles and permissions. -- Roles is a separate User Operations registry. Role definitions - compose discrete capabilities through a dense permission catalogue with an - explicit selected count and textual `GRANTED` / `NOT GRANTED` states. The - core Admin role is deletion-protected because it provides the system's - unrestricted access mechanism. Full administrators may edit every role and - delete seeded operational roles; deletion records a persistent tombstone so - migrations do not recreate the role. Delegated access managers may edit - seeded operational roles but may delete only custom roles. Every deletion - requires an exact-name confirmation that states how many agents will - immediately lose the role. Keep Delete separated from Edit and use the danger - treatment only inside this confirmed path. - Delegated access managers must never be able to grant `system.admin`; both - option visibility and server-side validation enforce this boundary. Role - mutations are recorded in the audit log with their previous and resulting - permission and assignment evidence. + community content moderation and does not imply ISAC access. Operational ISAC + access is delegated separately through scoped roles and permissions. +- Roles is a separate User Operations registry. Role definitions compose + discrete capabilities through a dense permission catalogue with an explicit + selected count and textual `GRANTED` / `NOT GRANTED` states. The core Admin + role is deletion-protected because it provides the system's unrestricted + access mechanism. Full administrators may edit every role and delete seeded + operational roles; deletion records a persistent tombstone so migrations do + not recreate the role. Delegated access managers may edit seeded operational + roles but may delete only custom roles. Every deletion requires an exact-name + confirmation that states how many agents will immediately lose the role. Keep + Delete separated from Edit and use the danger treatment only inside this + confirmed path. Delegated access managers must never be able to grant + `system.admin`; both option visibility and server-side validation enforce this + boundary. Role mutations are recorded in the audit log with their previous and + resulting permission and assignment evidence. - Audit History belongs to a separate `SYSTEM OVERSIGHT` navigation domain. Audit records are append-only operational evidence and show the operator, categorical action, target identifier, timestamp, and expandable before/after - JSON snapshots. Search and category/action filters run before pagination. - The Auditor role grants read-only `audit.view` access plus entry to ISAC; - audit visibility never implies mutation, user-management, or game-data access. + JSON snapshots. Search and category/action filters run before pagination. The + Auditor role grants read-only `audit.view` access plus entry to ISAC; audit + visibility never implies mutation, user-management, or game-data access. - Supported audit events may expose a permissioned compensating-action control. Reversion uses a danger-styled `ATTEMPT REVERT` entry action followed by a modal that explains the original action, operator, target, and conflict checks. The orange Confirm action is the only submission path. ISAC compares current state with the original event’s after-snapshot before restoring the before-snapshot; newer work blocks the operation. Successful reversions append - a new `audit.reverted` event and never alter the original history. `audit.view` - remains read-only; `audit.revert` is independently assigned. + a new `audit.reverted` event and never alter the original history. + `audit.view` remains read-only; `audit.revert` is independently assigned. - System Metrics is the second read-only System Oversight surface. It uses a compact KPI row for headline process health and a two-column grid of dense runtime, host, database, and service-volume panels. Refresh is explicit and duplicate-safe; optional auto-refresh is visibly selected and uses a restrained ten-second interval. Metrics must not expose environment values, credentials, full filesystem paths, or other deployment secrets. The - `metrics.view` permission is independent of mutation access and is included - in the read-only Auditor role. External-service capacity reuses the same - metric panel: show distinct provider pools, the last authoritative check, - configured alert thresholds, and explicit `OK`, `WARNING`, `CRITICAL`, or - unavailable text. Green, orange, and red reinforce these states but never - replace their labels. + `metrics.view` permission is independent of mutation access and is included in + the read-only Auditor role. External-service capacity reuses the same metric + panel: show distinct provider pools, the last authoritative check, configured + alert thresholds, and explicit `OK`, `WARNING`, `CRITICAL`, or unavailable + text. Green, orange, and red reinforce these states but never replace their + labels. - Account deletion uses a collapsed danger zone, explicit identity confirmation, and plain-language retention guidance. Soft-deleted accounts retain authored content under a deleted-agent identity while releasing credentials, usernames, external identity links, sessions, roles, and permissions. -- Threshold-based set bonuses use compact `effect-band` groups. Each threshold is - displayed as a fixed piece-count marker followed by its mapped stat and value, - keeping progression readable in editors and table summaries. -- Multi-value compatibility fields use a dense `selection-grid` of checkbox - rows with a visible selected-count summary. Keep the entire row clickable and +- Threshold-based set bonuses use compact `effect-band` groups. Each threshold + is displayed as a fixed piece-count marker followed by its mapped stat and + value, keeping progression readable in editors and table summaries. +- Multi-value compatibility fields use a dense `selection-grid` of checkbox rows + with a visible selected-count summary. Keep the entire row clickable and preserve native checkbox semantics for keyboard and assistive-technology use. - When compatibility is conditional on another field, show only valid choices, clear stale selections when the controlling field changes, and state required @@ -861,15 +901,18 @@ error, or danger indicators. - Every interactive element needs a pointer cursor unless disabled. - Buttons have hover, pressed, focus-visible, pending, and disabled states. -- Forms that send requests should use `PendingForm.svelte` or equivalent enhancement to prevent duplicate submissions. -- Pending buttons use `aria-busy="true"` and remain disabled until the request completes. +- Forms that send requests should use `PendingForm.svelte` or equivalent + enhancement to prevent duplicate submissions. +- Pending buttons use `aria-busy="true"` and remain disabled until the request + completes. - Respect `prefers-reduced-motion`. ## Color semantics - Orange: primary action, current location, selected operational value. - Green: online, active, configured, or successful. -- Red: destructive action, error, or offensive classification when context requires it. +- Red: destructive action, error, or offensive classification when context + requires it. - Blue: defensive or informational classification. - Gray: inactive, archived, unavailable, or supporting metadata. @@ -890,7 +933,8 @@ Do not communicate state by color alone; include a label, icon, or pattern. 3. Start with the module toolbar and KPI row. 4. Reuse dashboard editor and table classes. 5. Use the shared tokens instead of introducing near-duplicate colors. -6. Verify keyboard focus, pending behavior, empty states, mobile layout, and archived data behavior. +6. Verify keyboard focus, pending behavior, empty states, mobile layout, and + archived data behavior. Document a new interface pattern here when you introduce it. @@ -913,48 +957,48 @@ Agent portraits use a circular crop in identity headers and square tactical thumbnails in selectors. Account Control shows the current portrait first and keeps preset and upload controls behind an explicit Edit Avatar action. Preset choices appear in a compact four-column grid; the active choice uses the orange -action border and an explicit pressed state. -Custom images are normalized server-side to a metadata-free 256 × 256 WebP. -Selectors must retain visible hover, focus-visible, pressed, pending, and -disabled states, and collapse naturally with their containing account console. -ISAC user dossiers show the current portrait, distinguish preset and custom -sources textually, and place destructive custom-image removal behind the -existing moderation permission and pending-action pattern. -Moderation actions use full-width engineered controls: neutral outlined -buttons for reversible identity changes and muted red controls for destructive -or restrictive changes. Every control must expose pointer, hover, -focus-visible, pressed, disabled, and inline pending-spinner states; adjacent -navigation links use the same height, typography, and interaction motion. +action border and an explicit pressed state. Custom images are normalized +server-side to a metadata-free 256 × 256 WebP. Selectors must retain visible +hover, focus-visible, pressed, pending, and disabled states, and collapse +naturally with their containing account console. ISAC user dossiers show the +current portrait, distinguish preset and custom sources textually, and place +destructive custom-image removal behind the existing moderation permission and +pending-action pattern. Moderation actions use full-width engineered controls: +neutral outlined buttons for reversible identity changes and muted red controls +for destructive or restrictive changes. Every control must expose pointer, +hover, focus-visible, pressed, disabled, and inline pending-spinner states; +adjacent navigation links use the same height, typography, and interaction +motion. ### Weapon definition editor Complex progression definitions use a stacked control-surface editor: identity and fixed values first, then parallel tier cards, mutually exclusive talent -pools, and the augmentation sequence. Tier cards retain -identical row ordering so values can be compared horizontally on wide screens -and stack without changing meaning on narrow screens. System-derived augment -steps appear as read-only numbered cells; editable effects use the shared -attribute/value row pattern. Exotic is a status layered onto a weapon family, -never a replacement for the family classification. Exotic definitions add a -dedicated exotic-only talent after the two standard talent pools; their sixth -augmentation cell communicates that this talent advances to Tier II. -The exotic talent is authored in the Talent Registry under the `Exotic Weapon` -category. The weapon editor uses the shared searchable assignment pattern to -reference one registered exotic talent. Each option states whether another -weapon owns it and whether contributors have documented Tier II. Assigned -talents remain visible but unavailable to other weapon records. The selected -talent stays removable from its current weapon. +pools, and the augmentation sequence. Tier cards retain identical row ordering +so values can be compared horizontally on wide screens and stack without +changing meaning on narrow screens. System-derived augment steps appear as +read-only numbered cells; editable effects use the shared attribute/value row +pattern. Exotic is a status layered onto a weapon family, never a replacement +for the family classification. Exotic definitions add a dedicated exotic-only +talent after the two standard talent pools; their sixth augmentation cell +communicates that this talent advances to Tier II. The exotic talent is authored +in the Talent Registry under the `Exotic Weapon` category. The weapon editor +uses the shared searchable assignment pattern to reference one registered exotic +talent. Each option states whether another weapon owns it and whether +contributors have documented Tier II. Assigned talents remain visible but +unavailable to other weapon records. The selected talent stays removable from +its current weapon. The Talent Registry owns the exotic talent name, icon, description template, Tier previews, and mechanic program. Its explicit Tier II documentation choice uses `DOCUMENTED` and `NOT YET DOCUMENTED`. Tier I remains required in both -states. The registry shows `NOT YET DOCUMENTED` in the Tier II column instead -of a zero value or synthesized description. +states. The registry shows `NOT YET DOCUMENTED` in the Tier II column instead of +a zero value or synthesized description. Place shared weapon-category bonuses above the weapon registry in one collapsed profile console. Its six category buttons show the three inherited Attribute -values and open one category editor. The editor keeps Attribute identities -fixed and exposes only their positive numeric values. Saving closes the editor; +values and open one category editor. The editor keeps Attribute identities fixed +and exposes only their positive numeric values. Saving closes the editor; validation preserves its values and error state. Standard and Exotic weapons inherit the profile for their underlying Shotgun, Rifle, MMR, AR, LMG, or SMG category. Do not repeat these controls inside individual weapon editors. @@ -968,17 +1012,17 @@ results. Attribute assignments use `UNIT · %`, `UNIT · s`, or `UNIT · FLAT`; search and accessible names include that unit. Mutually exclusive pools pass their assigned IDs to one another as exclusions so invalid combinations cannot be selected. Keyboard focus, pressed state, empty results, and overflow remain -explicit. -Where a relationship has a global slot invariant, filter each assignment -control at the data boundary instead of presenting cross-slot choices. Weapon -talents are classified once as Talent 1 / Top or Talent 2 / Bottom in the -talent registry; weapon editors only curate subsets from the matching slot. +explicit. Where a relationship has a global slot invariant, filter each +assignment control at the data boundary instead of presenting cross-slot +choices. Weapon talents are classified once as Talent 1 / Top or Talent 2 / +Bottom in the talent registry; weapon editors only curate subsets from the +matching slot. ### Game-semantic iconography Canonical game icons are registered in `src/lib/game-icons.ts` and rendered -through `GameIcon.svelte`; do not repeat asset paths in individual features. -Use damage icons beside Blast, Pierce, and Shredding classifications, and core +through `GameIcon.svelte`; do not repeat asset paths in individual features. Use +damage icons beside Blast, Pierce, and Shredding classifications, and core attribute icons beside Firepower, Toughness, and Engineering values. The same registry reserves the Bulwark, Combat Medic, Demolitionist, Tech Operator, Vanguard, Legendary, and SHD-ticket assets for controls or data that explicitly @@ -993,14 +1037,13 @@ asset's aspect ratio with `object-fit: contain` and scale through the shared ### Item quality -Item quality is a global semantic scale: Standard uses -`--quality-standard` green, Specialized uses `--quality-specialized` blue, -Superior uses `--quality-superior` purple, and High-end uses -`--quality-high-end` yellow. Apply the matching `.quality-*` utility wherever -an item’s persisted quality is shown. Registry labels use `.quality-chip` so -quality remains explicit in text and never depends on color alone. These colors -identify item quality; orange remains reserved for actions and current -selection. +Item quality is a global semantic scale: Standard uses `--quality-standard` +green, Specialized uses `--quality-specialized` blue, Superior uses +`--quality-superior` purple, and High-end uses `--quality-high-end` yellow. +Apply the matching `.quality-*` utility wherever an item’s persisted quality is +shown. Registry labels use `.quality-chip` so quality remains explicit in text +and never depends on color alone. These colors identify item quality; orange +remains reserved for actions and current selection. ### OS Protocol editor @@ -1020,26 +1063,25 @@ icon-backed labels. Both controls collapse below the name on narrow screens. The shared secondary-profile matrix is collapsed by default because it is configuration infrastructure rather than a frequent protocol-editing task. Its -disclosure header always exposes the configured total, uses a textual title, -and rotates the plus indicator when expanded. The full matrix remains keyboard +disclosure header always exposes the configured total, uses a textual title, and +rotates the plus indicator when expanded. The full matrix remains keyboard operable and follows the shared hover, focus-visible, and pressed states. ### Game-data icons OS Protocols, brand sets, and talents use the shared game-data icon upload -control in their ISAC editors. Existing artwork is previewed and may be -replaced or explicitly removed. Registry identity cells render a compact -thumbnail before the record name and retain a neutral textual fallback when no -icon is configured. +control in their ISAC editors. Existing artwork is previewed and may be replaced +or explicitly removed. Registry identity cells render a compact thumbnail before +the record name and retain a neutral textual fallback when no icon is +configured. -Source artwork must be a transparent SVG, PNG, WebP, or AVIF no larger than -2 MiB. SVG is treated only as an upload source and never served directly. -Accepted images are normalized asynchronously to a metadata-free transparent -256 × 256 WebP with `contain`, preserving the full symbol without avatar-style -cropping. Image labels remain visible in the UI, so icons supplement rather -than replace textual identity. Upload, replace, remove, pending, validation, -focus-visible, and narrow-screen states follow the shared engineered form -language. +Source artwork must be a transparent SVG, PNG, WebP, or AVIF no larger than 2 +MiB. SVG is treated only as an upload source and never served directly. Accepted +images are normalized asynchronously to a metadata-free transparent 256 × 256 +WebP with `contain`, preserving the full symbol without avatar-style cropping. +Image labels remain visible in the UI, so icons supplement rather than replace +textual identity. Upload, replace, remove, pending, validation, focus-visible, +and narrow-screen states follow the shared engineered form language. For compact editors such as brand sets, place the icon rail beside the record name and its primary configuration rows instead of giving the upload control a @@ -1067,8 +1109,8 @@ left-side position. The label must explicitly say “Unarchive” or “Restore never communicate restoration by color or icon alone. The dedicated unique-talent panel uses five interactive diamonds: no filled -diamonds is the base definition, and each selected upgrade adds one fifth of -the base until five upgrades show exactly twice the base effect. +diamonds is the base definition, and each selected upgrade adds one fifth of the +base until five upgrades show exactly twice the base effect. Diamonds are both a progression preview and an explicit numeric state; expose their upgrade number and `aria-pressed` state rather than relying on fill color. @@ -1079,40 +1121,38 @@ progression consistently in calculation and display code. ### ISAC editor lifecycle -Successful saves have a consistent outcome throughout data management: -editing an existing record closes the editor and returns the user to the registry; +Successful saves have a consistent outcome throughout data management: editing +an existing record closes the editor and returns the user to the registry; creating a new record keeps the editor open with a fresh blank definition for -rapid consecutive entry. Failed validation leaves the current editor and -entered values intact. Use a unique action result token so reactive handling -runs exactly once and does not create update loops. +rapid consecutive entry. Failed validation leaves the current editor and entered +values intact. Use a unique action result token so reactive handling runs +exactly once and does not create update loops. Tiered-definition registries should show their rendered tier effects when the -text is operationally useful. Use compact wrapping description cells rather -than replacing meaningful data with a generic “configured” label; retain an -explicit textual missing state when a tier has no rendered description. +text is operationally useful. Use compact wrapping description cells rather than +replacing meaningful data with a generic “configured” label; retain an explicit +textual missing state when a tier has no rendered description. -Talent mechanics are structured metadata, not free-form prose. Author -cooldowns on event branches, durations and maximum stacks on state pools, and -tier values on outcomes through `MechanicProgramEditor`. Description templates -use numbered expressions (`{1}`, `{2}`) for ordered outcomes and named -expressions (`{duration}`, `{max_stacks}`) for defined pool mechanics. Named -expressions are valid only when their corresponding value exists. Cooldown is -never embedded in the description: render it consistently as `Talent -Cooldown: [value] seconds` beneath the talent identity or description. Do not -imply an absent mechanic with a zero value; omit the cooldown line when no -branch defines one. +Talent mechanics are structured metadata, not free-form prose. Author cooldowns +on event branches, durations and maximum stacks on state pools, and tier values +on outcomes through `MechanicProgramEditor`. Description templates use numbered +expressions (`{1}`, `{2}`) for ordered outcomes and named expressions +(`{duration}`, `{max_stacks}`) for defined pool mechanics. Named expressions are +valid only when their corresponding value exists. Cooldown is never embedded in +the description: render it consistently as `Talent Cooldown: [value] seconds` +beneath the talent identity or description. Do not imply an absent mechanic with +a zero value; omit the cooldown line when no branch defines one. ### Registry pagination Every primary ISAC registry uses server-side pagination. The shared control appears immediately above and visually attached to its table. It shows the -visible record range and total, previous/next navigation, a compact -window of page numbers, and row-count presets of 10, 25, 50, and 100. Existing -filter query parameters must survive page navigation, while changing page size -returns to page one. Summary metrics remain global or filter-global and must -never silently describe only the visible page. On narrow screens, retain -previous, next, current page, and page-size controls before optional page -numbers. +visible record range and total, previous/next navigation, a compact window of +page numbers, and row-count presets of 10, 25, 50, and 100. Existing filter +query parameters must survive page navigation, while changing page size returns +to page one. Summary metrics remain global or filter-global and must never +silently describe only the visible page. On narrow screens, retain previous, +next, current page, and page-size controls before optional page numbers. The row-count presets remain available even when the current result fits on one page. Omit only the page navigation when there is no second page. Do not render @@ -1124,16 +1164,16 @@ short typing debounce, so do not place a redundant Search button beside the field or label the field as live search. Show `SEARCHING…` only while navigation is pending, and provide an explicit clear action whenever a query is active. Search resets the page number while preserving the selected KPI filter and -row-count preset; page navigation preserves the query. Search applies to -the complete filtered registry before pagination, including visible pending -proposals, and never only to the current page. Specialized oversight and -history searches may remain in their richer filter consoles when they search -evidence beyond a single identity column. +row-count preset; page navigation preserves the query. Search applies to the +complete filtered registry before pagination, including visible pending +proposals, and never only to the current page. Specialized oversight and history +searches may remain in their richer filter consoles when they search evidence +beyond a single identity column. -At intermediate widths the search spans the pagination rail above its range -and navigation controls. At mobile widths all groups stack, the input retains a -usable width, and search, clear, previous, and next remain explicit keyboard -and touch targets. +At intermediate widths the search spans the pagination rail above its range and +navigation controls. At mobile widths all groups stack, the input retains a +usable width, and search, clear, previous, and next remain explicit keyboard and +touch targets. ### Sortable registry columns @@ -1141,8 +1181,8 @@ Every meaningful data column in an ISAC table uses the shared sortable-column header. The first activation sorts a new column in its most useful default direction; later activations reverse the direction. Place a neutral bidirectional indicator immediately before each inactive column label and an -orange directional indicator immediately before the active column label. -Action columns do not sort. +orange directional indicator immediately before the active column label. Action +columns do not sort. Sorting applies on the server before pagination. Store the selected column and direction in the URL, preserve active search, filters, and row-count settings, @@ -1162,27 +1202,27 @@ labels and sort state do not collapse into ambiguous controls. The KPI row above an ISAC registry is an operational filter surface, not passive decoration. Use `KpiFilters.svelte` when each metric describes a meaningful -record subset. The first card returns to the complete current registry or search; -the remaining cards filter by useful classifications such as active state, -category, equipment behavior, or restriction state. +record subset. The first card returns to the complete current registry or +search; the remaining cards filter by useful classifications such as active +state, category, equipment behavior, or restriction state. Filtering is server-backed and occurs before pagination. Filter links preserve compatible search and page-size parameters, reset the page number, remain bookmarkable, and expose the selected state with both orange emphasis and `aria-current`. Cards require pointer, hover, focus-visible, pressed, and -textually identifiable active states. Every card explicitly says `FILTER →` -or `SELECTED`; do not rely on KPI-card styling alone to communicate that the +textually identifiable active states. Every card explicitly says `FILTER →` or +`SELECTED`; do not rely on KPI-card styling alone to communicate that the metrics are controls. Counts describe the full search scope and never only the -visible page. Replace aggregate facts that cannot filter the -affected registry with actionable subsets. +visible page. Replace aggregate facts that cannot filter the affected registry +with actionable subsets. ### Registry row interaction -Desktop registry rows are passive data containers unless the entire row opens -a detail view. Do not apply a whole-row hover surface when only -the trailing Edit or View action is interactive. Render that action as a -compact bordered control with hover, focus-visible, and pressed states so its -interaction boundary is unmistakable. +Desktop registry rows are passive data containers unless the entire row opens a +detail view. Do not apply a whole-row hover surface when only the trailing Edit +or View action is interactive. Render that action as a compact bordered control +with hover, focus-visible, and pressed states so its interaction boundary is +unmistakable. On mobile, a row may become a full-width detail target when the route provides an intentional mobile detail interaction. In that case retain the explicit @@ -1191,10 +1231,10 @@ touch target; do not infer full-row behavior from desktop hover styling. ### Image uploads -All image uploads use the shared SHD drop-zone treatment and expose three equivalent -selection paths: a file picker, drag and drop, and clipboard paste. The control shows -the selected filename and a local preview, but server-side format, size, transparency, -and normalization checks remain authoritative. +All image uploads use the shared SHD drop-zone treatment and expose three +equivalent selection paths: a file picker, drag and drop, and clipboard paste. +The control shows the selected filename and a local preview, but server-side +format, size, transparency, and normalization checks remain authoritative. ### Moderated data states @@ -1203,11 +1243,12 @@ access. Use a compact access banner above the module rather than changing the registry layout. Pending proposal counts appear as orange numeric indicators in data-navigation items. Proposal lifecycle states use text as well as color: pending is orange/actionable, approved is green/configured, and rejected or -failed is red/archived. Review decisions always use the standard ISAC confirmation -surface and must never apply a proposal from a table-row click alone. +failed is red/archived. Review decisions always use the standard ISAC +confirmation surface and must never apply a proposal from a table-row click +alone. -Eligible contributors enter ISAC data modules in read-only mode by default. -The access banner pairs the explicit `READ-ONLY DATA ACCESS` state with a compact +Eligible contributors enter ISAC data modules in read-only mode by default. The +access banner pairs the explicit `READ-ONLY DATA ACCESS` state with a compact action that opens the versioned contributor guidelines. Contributor mode may only be enabled after affirmative checkbox acceptance. Once enabled, the banner changes to orange, explains that saves enter moderation, and offers a @@ -1219,19 +1260,20 @@ Show contributor access banners only on Data Management modules. Show the data-mode controls and language from User Operations, System Oversight, and System Configuration because their permissions govern access independently. Read-only mode is an inspection surface: hide add controls, row edit actions, -mutation forms, and editors opened through URL state. Dimming or disabling -those controls is insufficient. Registry navigation, KPI filters, pagination, search, evidence, -and other non-mutating inspection controls remain visible and interactive. +mutation forms, and editors opened through URL state. Dimming or disabling those +controls is insufficient. Registry navigation, KPI filters, pagination, search, +evidence, and other non-mutating inspection controls remain visible and +interactive. The ISAC command-status cluster includes system state, access state, the themed -ISAC node identifier, the CI build number, and an explicit `DATA MODE` cell. -The data-mode cell reports the highest effective authority in this order: -`ADMIN`, `OVERSIGHT`, `DATA MANAGER`, `CONTRIBUTOR`, then `READ-ONLY`. Use a -neutral treatment for read-only, orange for contributor, blue for Oversight -authority, and green for direct-management authority. Every cell requires a -label and value because color provides supplemental information. At medium -widths, arrange the cells in a two-column grid and let the data-mode cell span -both columns. On narrow screens, the cluster spans the command header width. +ISAC node identifier, the CI build number, and an explicit `DATA MODE` cell. The +data-mode cell reports the highest effective authority in this order: `ADMIN`, +`OVERSIGHT`, `DATA MANAGER`, `CONTRIBUTOR`, then `READ-ONLY`. Use a neutral +treatment for read-only, orange for contributor, blue for Oversight authority, +and green for direct-management authority. Every cell requires a label and value +because color provides supplemental information. At medium widths, arrange the +cells in a two-column grid and let the data-mode cell span both columns. On +narrow screens, the cluster spans the command header width. ### Oversight proposal corrections @@ -1251,13 +1293,14 @@ correction fields to one column on narrow screens. Proposal evidence is reviewer-facing product data, not JSON-first diagnostics. Open review in a wide, scroll-contained confirmation surface and present live and proposed values side by side using the visual structure and terminology of -the source module's editor. Label the two states explicitly as `CURRENT LIVE / -BEFORE` and `PROPOSED / AFTER`; for additions, replace the empty live panel with -an explicit `NEW RECORD` state. Stack comparisons on narrow screens. Raw stored -payloads remain available in a collapsed technical-evidence disclosure for -troubleshooting and audit support, but must not be the primary review surface. -Each module should add a purpose-built preview as its editor matures; until then, -use compact labeled field cards rather than an exposed JSON document. +the source module's editor. Label the two states explicitly as +`CURRENT LIVE / BEFORE` and `PROPOSED / AFTER`; for additions, replace the empty +live panel with an explicit `NEW RECORD` state. Stack comparisons on narrow +screens. Raw stored payloads remain available in a collapsed technical-evidence +disclosure for troubleshooting and audit support, but must not be the primary +review surface. Each module should add a purpose-built preview as its editor +matures; until then, use compact labeled field cards rather than an exposed JSON +document. Data-navigation links with pending proposals use a dedicated four-column layout: module code, flexible label, square orange count cell, then navigation arrow. @@ -1287,9 +1330,9 @@ the compact 58-pixel cells reset the ordinary text-and-icon grid to a single centered track, and the transparent icon fills that square. Align the fieldset with adjacent compact controls rather than inheriting the standard radio-grid top margin. Visible truncated names are omitted. Preserve the complete name as -the radio control's accessible label and -as hover text. Selection, hover, pressed, and keyboard-focus states remain on -the square perimeter; missing icons fall back to the entity initials. +the radio control's accessible label and as hover text. Selection, hover, +pressed, and keyboard-focus states remain on the square perimeter; missing icons +fall back to the entity initials. Builder level controls enforce item availability without changing the scaling curve's origin. T0 and T1 gear and weapons allow levels 1–50; T2 gear and @@ -1297,8 +1340,9 @@ weapons allow levels 40–50 because lower-level T2 items are not found in game. Their base-value progression still follows the same level-1-based curve. Selecting T2 on a weapon below level 40 raises the configured level to 40 and calculates the ordinary level-40 curve value; never rebase level 40 as T2 level + 1. Slider bounds, direct-input validation, displayed bounds, and clamping must -all enforce the same availability minimum. + all enforce the same availability minimum. ISAC ranges that represent observed values retain their explicit reference level. The progression curve still has a level-1 origin, but builder scaling @@ -1312,16 +1356,16 @@ directly by `weaponMultiplier(selectedLevel)`. Changing weapon level remaps the selected damage value into the newly calculated range. Shared core attributes remain level-50 reference profiles and use ratio scaling independently. -Gear base Health and Armor follow the same hidden-base principle. Multiply -their stored per-item endpoints directly by `gearMultiplier(selectedLevel)`, -truncate only for display, and remap the user's selected value proportionally -when gear level changes. Do not apply this rule to rolled Attributes or the -shared core-allocation profile, which retain independent reference semantics. +Gear base Health and Armor follow the same hidden-base principle. Multiply their +stored per-item endpoints directly by `gearMultiplier(selectedLevel)`, truncate +only for display, and remap the user's selected value proportionally when gear +level changes. Do not apply this rule to rolled Attributes or the shared +core-allocation profile, which retain independent reference semantics. ### Shared core allocation -Firepower, Toughness, and Engineering use one system-wide tier profile shared -by gear and weapons. Maintain the T0/T1/T2 nominal ranges in ISAC Global +Firepower, Toughness, and Engineering use one system-wide tier profile shared by +gear and weapons. Maintain the T0/T1/T2 nominal ranges in ISAC Global Configuration rather than repeating them on every item editor. The initial level-50 profiles are T0 `30–78`, T1 `36–94`, and T2 `44–112`. Global configuration mutations follow ordinary contributor moderation and append-only @@ -1329,14 +1373,14 @@ ISAC audit rules. The three nominal ranges define a combined allocation pool, not three hard per-stat clamps. At a given level, the valid total lies between three times the -scaled nominal minimum and three times the scaled nominal maximum. An -individual core may be zero or exceed the nominal maximum when points are -redistributed, up to the entire pool maximum. Builder gear and weapon editors -show three sliders/direct inputs plus an explicit current-total versus -valid-pool readout; out-of-pool totals use textual red/restricted feedback. -Both equipment families contribute their configured cores to the build summary. -Treat this redistribution model as empirical and keep it configurable as more -in-game edge cases are observed. +scaled nominal minimum and three times the scaled nominal maximum. An individual +core may be zero or exceed the nominal maximum when points are redistributed, up +to the entire pool maximum. Builder gear and weapon editors show three +sliders/direct inputs plus an explicit current-total versus valid-pool readout; +out-of-pool totals use textual red/restricted feedback. Both equipment families +contribute their configured cores to the build summary. Treat this +redistribution model as empirical and keep it configurable as more in-game edge +cases are observed. Pending records may be used as dependencies in later proposals by any contributor. Render these choices in the ordinary assignment control with a @@ -1370,16 +1414,16 @@ empty role assignment for an account with no effective access. ### Mobile registry detail targets On narrow screens, a non-editing registry row with one canonical detail or -review destination becomes a full-row tap target. Use a real stretched link -when the destination is another route and a transparent button when it opens an +review destination becomes a full-row tap target. Use a real stretched link when +the destination is another route and a transparent button when it opens an in-page detail surface. Keep the explicit desktop `VIEW`, `REVIEW`, or `EVIDENCE` action, and add a visible mobile cue at the bottom of the row so the interaction is discoverable. The target must expose an accurate accessible name, show the standard orange -focus-visible outline, provide pressed feedback, and meet a minimum 48-pixel -tap height. Do not apply this pattern to edit, delete, approve, or other -mutating actions, and do not stretch a target across rows containing independent +focus-visible outline, provide pressed feedback, and meet a minimum 48-pixel tap +height. Do not apply this pattern to edit, delete, approve, or other mutating +actions, and do not stretch a target across rows containing independent interactive controls. ### Avatar Oversight @@ -1405,10 +1449,10 @@ Authenticated navigation exposes a compact inbox glyph with a numeric unread badge; mobile navigation provides the same explicit `INBOX` destination and count. Notifications live under the agent-profile namespace and use an explicit profile context trail back to account controls. The inbox is a dense -communication registry with a source rail, category, -title, timestamp, body, destination, and textual read state. Unread messages use -the orange action rail. Mandatory ISAC notices use the blue operational rail and -must always remain visible in the in-app inbox. +communication registry with a source rail, category, title, timestamp, body, +destination, and textual read state. Unread messages use the orange action rail. +Mandatory ISAC notices use the blue operational rail and must always remain +visible in the in-app inbox. Preferences are channel-by-category rather than global. Each category controls in-app, browser, and email delivery independently. ISAC in-app delivery is @@ -1430,8 +1474,8 @@ Inbox messages use sender identity and semantic tone. ISAC sources use the SHD system mark, blue system labeling, and an explicit `SYSTEM` descriptor; community activity uses an agent portrait and `AGENT` descriptor. Positive outcomes use green, adverse or corrective outcomes use red, and informational -content stays neutral. Message titles and body copy state the actual outcome; -do not expose internal sentiment labels such as `POSITIVE`, `NEGATIVE`, or +content stays neutral. Message titles and body copy state the actual outcome; do +not expose internal sentiment labels such as `POSITIVE`, `NEGATIVE`, or `NEUTRAL`. Reserve visible badges for operationally meaningful severity such as `WARNING` or `ERROR`, while the message wording remains understandable without depending on color alone. @@ -1440,9 +1484,9 @@ The inbox command header pairs the page identity with a compact bordered unread summary and a same-height preferences action. Filter selects and adjacent actions share a 40-pixel control height and vertically centered labels. Within each message, the tone/title/timestamp header uses modest vertical padding and a -subtle lower rule: keep it dense, but never collapse it into a text-height strip. -On mobile the unread summary and preferences action share one grid row, while -filter controls stack into full-width, minimum 44-pixel targets. +subtle lower rule: keep it dense, but never collapse it into a text-height +strip. On mobile the unread summary and preferences action share one grid row, +while filter controls stack into full-width, minimum 44-pixel targets. Page hierarchy uses the shared eyebrow breadcrumb rather than a separate breadcrumb row above a command header. It retains the short orange rule, renders @@ -1458,14 +1502,14 @@ must use their named component classes rather than the global application `VIEW DETAILS` opens the standard in-page modal rather than navigating immediately. The modal repeats sender, source, category, semantic tone, time, full message body, read control, and related-page destinations. Keep the sender -header and utility footer fixed while the message and action area scrolls. -Group destination actions beneath an `AVAILABLE ACTIONS` label in a regular -grid; reserve the footer for message state and closing the modal. Opening the -modal moves focus to its close control. Trap keyboard focus inside it, support -Escape and backdrop dismissal, and return focus to the originating `VIEW -DETAILS` control after closing. Inbox cards show up to four lines of body copy -at reading size before truncation. Place the per-message read control beside the -timestamp so message state remains separate from workflow actions. +header and utility footer fixed while the message and action area scrolls. Group +destination actions beneath an `AVAILABLE ACTIONS` label in a regular grid; +reserve the footer for message state and closing the modal. Opening the modal +moves focus to its close control. Trap keyboard focus inside it, support Escape +and backdrop dismissal, and return focus to the originating `VIEW DETAILS` +control after closing. Inbox cards show up to four lines of body copy at reading +size before truncation. Place the per-message read control beside the timestamp +so message state remains separate from workflow actions. ISAC message content uses the structured system-transmission body. A compact blue `ISAC TRANSMISSION` kicker precedes the summary, and recognized supporting @@ -1478,10 +1522,10 @@ Inbox entries expose two distinct actions. `VIEW DETAILS` opens the complete message when its four-line preview truncates longer copy. A separate bordered orange response action opens the known workflow and uses specific language such as `UPLOAD NEW AVATAR`, `REVISE CONTRIBUTION`, or `VIEW BUILD & REPLY`; never -substitute a generic destination label when the notification type is known. -Lay out these controls in an auto-fitting grid so each label remains intact. -The detail modal uses the same response labels in a three-column grid where -space permits. On mobile, response actions become full-width 44-pixel targets. +substitute a generic destination label when the notification type is known. Lay +out these controls in an auto-fitting grid so each label remains intact. The +detail modal uses the same response labels in a three-column grid where space +permits. On mobile, response actions become full-width 44-pixel targets. Inbox query controls use two compact radio-backed segmented selectors. `SHOW` switches between all, unread, and read messages. `SORT` offers newest, oldest, @@ -1509,9 +1553,9 @@ mobile-target states so modules do not reimplement them. Numeric inputs inside ISAC use overwrite-first focus behavior: focusing an enabled `type="number"` control selects its complete current value so a -contributor can immediately type a replacement instead of appending to a -default zero. This behavior belongs to the shared ISAC layout boundary and must -not be reimplemented in individual modules. Disabled numeric controls remain +contributor can immediately type a replacement instead of appending to a default +zero. This behavior belongs to the shared ISAC layout boundary and must not be +reimplemented in individual modules. Disabled numeric controls remain unfocusable and unchanged. `SegmentedChoice` supports two restrained density variants. Icon-only segments @@ -1533,11 +1577,11 @@ talent columns, use a two-by-two preview grid, and retain 44-pixel targets. ### ISAC record identity controls -Game-data icon editors use the shared `--isac-icon-editor-width` footprint -(150 pixels on desktop) regardless of module. Identity layouts allocate that -fixed column to `IconUploadField`, give the record name the flexible dominant -column, and stack both controls at mobile widths. Do not enlarge an upload -drop-zone to fill spare identity-row space. +Game-data icon editors use the shared `--isac-icon-editor-width` footprint (150 +pixels on desktop) regardless of module. Identity layouts allocate that fixed +column to `IconUploadField`, give the record name the flexible dominant column, +and stack both controls at mobile widths. Do not enlarge an upload drop-zone to +fill spare identity-row space. Icon-enabled consecutive-entry editors pass a successful-add result token into the shared upload control. Advancing that token clears the native file input, @@ -1561,11 +1605,11 @@ Records with established visual identity may supply an optional square icon. Standard assignment lists show it at 32 pixels in result rows and 24 pixels in selected chips. An icon-first multiselect may use the `visual-grid` presentation for a finite visual registry. Its compact tiles use a 38-pixel icon without -persistent tile text. Hover and keyboard focus reveal the record name, while -the accessible label includes the name and assignment state. The orange inset -edge marks selected tiles. Weapon Talent 1 and Talent 2 pools and the Backpack -and Body Armor talent pools use this grid. Use the shared missing-item artwork -when a record lacks an icon. Render the full eligible set without a search, +persistent tile text. Hover and keyboard focus reveal the record name, while the +accessible label includes the name and assignment state. The orange inset edge +marks selected tiles. Weapon Talent 1 and Talent 2 pools and the Backpack and +Body Armor talent pools use this grid. Use the shared missing-item artwork when +a record lacks an icon. Render the full eligible set without a search, result-count row, item cap, or internal scroll region; the section header owns the selected count. Reflow tiles without changing keyboard order. @@ -1580,10 +1624,10 @@ Every ISAC relationship to an Attribute uses `SearchableAssignment`; native attribute dropdowns are not permitted because the registry is expected to contain hundreds of records. Use full mode for primary editor sections and multi-value pools. Use compact single mode inside repeated effect, bonus, or -augmentation rows: its selected record and search field remain inline while -the filtered result registry opens as an anchored overlay. The compact overlay -must layer above adjacent rows, retain pending-record labels, and collapse to -one column on narrow screens. +augmentation rows: its selected record and search field remain inline while the +filtered result registry opens as an anchored overlay. The compact overlay must +layer above adjacent rows, retain pending-record labels, and collapse to one +column on narrow screens. Gear identity editing uses the shared segmented radio treatment for both Gear slot and item tier. Show all six slots together and all three tiers together so @@ -1591,27 +1635,27 @@ contributors can verify classification without opening a menu. The record name remains the dominant text field; at medium widths it occupies a full row above the two selectors. On narrow screens the slot selector becomes a three-by-two grid, both selectors use at least 44-pixel targets, and changing slot clears -incompatible Talent relationships exactly as the former selector did. Gear -slot segments use icon-only controls with accessible slot names; registry labels +incompatible Talent relationships exactly as the former selector did. Gear slot +segments use icon-only controls with accessible slot names; registry labels retain icon and text for rapid table scanning. Tier segments use the compact game notation `T0`, `T1`, and `T2`, and rely on the selected rail and color; do -not expand these to “Tier 0” inside -dense segmented controls. On desktop, icon-only Gear slot segments are square -32-pixel cells so their classification rail stays compact and the adjacent -record-name input receives the dominant width. Increase those cells to square -44-pixel targets at mobile sizes without reintroducing visible slot text. +not expand these to “Tier 0” inside dense segmented controls. On desktop, +icon-only Gear slot segments are square 32-pixel cells so their classification +rail stays compact and the adjacent record-name input receives the dominant +width. Increase those cells to square 44-pixel targets at mobile sizes without +reintroducing visible slot text. Weapon-category editing follows the same compact icon-rail treatment: Shotgun, Rifle, MMR, AR, LMG, and SMG use their canonical weapon silhouettes in square 32-pixel desktop cells and 44-pixel mobile cells, with accessible names and hover titles retained. Exotic remains an independent icon-backed status because -an Exotic weapon must also retain its underlying weapon category. Weapon -Damage Type follows the same icon-only treatment when canonical Blast, Pierce, -and Shredding marks are available. Exotic status is a single square checkbox -whose icon, orange rail, and background show its checked state; no visible -“Exotic weapon” label is repeated inside the control. Weapon registry rows show -both the category silhouette and, when applicable, the Exotic mark alongside -the textual class for rapid scanning. +an Exotic weapon must also retain its underlying weapon category. Weapon Damage +Type follows the same icon-only treatment when canonical Blast, Pierce, and +Shredding marks are available. Exotic status is a single square checkbox whose +icon, orange rail, and background show its checked state; no visible “Exotic +weapon” label is repeated inside the control. Weapon registry rows show both the +category silhouette and, when applicable, the Exotic mark alongside the textual +class for rapid scanning. Weapon Tier Progression begins at the selected Native Tier. Lower tier cards remain visible for orientation but are disabled, desaturated, and explicitly @@ -1639,20 +1683,20 @@ editor. Successful saves close the profile editor and return to the registry. Gear Tactical Augment profiles use this pattern for all six gear types. Backpacks and Body Armor expose four required slots; Holsters, Gloves, Knee -Pads, and Masks expose three. Augment 01 contains one searchable Attribute, -one numeric effect value, and an unlock cost. Later slots expose only their -unlock cost because their effects are fixed base-item upgrades: three-slot -profiles use +50% and +100%, while four-slot profiles use +40%, +70%, and -+100%. Holsters, Masks, and Backpacks modify the equipped item's base Health; -Gloves, Knee Pads, and Body Armor modify that item's base Health and Armor. -These percentages never apply to the overall character total. Every augment -slot stores separate non-negative whole-number unlock costs for item Tiers 0, -1, and 2. Show all three costs together in registry summaries and in a compact -three-column cost fieldset within the editor; the builder shows only the cost -matching the equipped item's tier. Fixed upgrades -use a green inherited-value panel to distinguish governed data from editable -orange actions. At narrow widths profile cards and augment editor cards reduce -to a single column while retaining 44-pixel interactive targets. +Pads, and Masks expose three. Augment 01 contains one searchable Attribute, one +numeric effect value, and an unlock cost. Later slots expose only their unlock +cost because their effects are fixed base-item upgrades: three-slot profiles use ++50% and +100%, while four-slot profiles use +40%, +70%, and +100%. Holsters, +Masks, and Backpacks modify the equipped item's base Health; Gloves, Knee Pads, +and Body Armor modify that item's base Health and Armor. These percentages never +apply to the overall character total. Every augment slot stores separate +non-negative whole-number unlock costs for item Tiers 0, 1, and 2. Show all +three costs together in registry summaries and in a compact three-column cost +fieldset within the editor; the builder shows only the cost matching the +equipped item's tier. Fixed upgrades use a green inherited-value panel to +distinguish governed data from editable orange actions. At narrow widths profile +cards and augment editor cards reduce to a single column while retaining +44-pixel interactive targets. ### Loadout workbench @@ -1660,8 +1704,8 @@ The builder is a three-rail tactical workbench: compact equipment positions on the left, the currently selected item editor in the center, and a sticky live calculation summary on the right. At medium widths the summary becomes a full-width lower rail; on mobile all three rails stack, with equipment first, -editing second, and results third. Equipment positions remain explicit even -when empty so loadout completeness is always visible. +editing second, and results third. Equipment positions remain explicit even when +empty so loadout completeness is always visible. Use the shared `BuilderPicker` for builder catalogs and Attribute pools. It combines an always-visible current selection with a searchable overlay, icon or @@ -1672,75 +1716,75 @@ such as the single-exotic-weapon rule are explained rather than hidden. Numeric instance values display their authoritative minimum and maximum beside the field and clamp to that range on completion. Always-on item rolls, fixed bonuses, inherited profiles, enabled augments, and activated set bonuses feed -the live totals immediately. Conditional mechanics state their calculation -mode and readiness. Maximum Potential uses their best-case state; Custom -Scenario uses the source-defined advanced assumptions saved with the loadout. -Exclude a mechanic that lacks a required assumption and label it `REQUIRES -ASSUMPTIONS`. This distinction must remain clear without relying on color. +the live totals immediately. Conditional mechanics state their calculation mode +and readiness. Maximum Potential uses their best-case state; Custom Scenario +uses the source-defined advanced assumptions saved with the loadout. Exclude a +mechanic that lacks a required assumption and label it `REQUIRES ASSUMPTIONS`. +This distinction must remain clear without relying on color. Loadout summaries name selected Talents, Attributes and their exact values, -nodes, and Skill Chips. Do not replace these selections with configured -item counts. Build identity includes a required name plus optional strengths -summary and usage guide. Shared build views include those author-written fields -so another player can understand the build's role and operating sequence. +nodes, and Skill Chips. Do not replace these selections with configured item +counts. Build identity includes a required name plus optional strengths summary +and usage guide. Shared build views include those author-written fields so +another player can understand the build's role and operating sequence. ### Pending ISAC registry records Pending additions appear directly in the same paginated ISAC registry as live records and in every compatible relationship picker. They use a restrained orange-tinted row, an explicit `PENDING` state, and proposer attribution. -Pending rows are read-only in module registries; changes remain the authority -of the Data Oversight review interface. Successful contributor submissions -refresh the registry immediately and follow the same editor lifecycle as a -direct save. Never rely on the tint alone to communicate moderation state. +Pending rows are read-only in module registries; changes remain the authority of +the Data Oversight review interface. Successful contributor submissions refresh +the registry immediately and follow the same editor lifecycle as a direct save. +Never rely on the tint alone to communicate moderation state. Gear and weapon level are builder-instance state, never part of an ISAC item definition. Each equipped item stores its own level from 1–50 in the local or -persisted build. Range endpoints are calculated from the documented level -curve and stored reference level; provisional portions of that curve must be +persisted build. Range endpoints are calculated from the documented level curve +and stored reference level; provisional portions of that curve must be identified in text as estimated. ### Registry relationship icon rails When a table relationship contains a small bounded set of visually identified -records, show a compact icon rail instead of a numeric relationship count. -Rails use 30-pixel square cells with four-pixel spacing, uploaded transparent -icons, full accessible names, and native hover titles. Missing artwork falls -back to a short orange text mark rather than removing the relationship. +records, show a compact icon rail instead of a numeric relationship count. Rails +use 30-pixel square cells with four-pixel spacing, uploaded transparent icons, +full accessible names, and native hover titles. Missing artwork falls back to a +short orange text mark rather than removing the relationship. Use this pattern only for small bounded sets that remain scannable, such as the two-to-four compatible Brand Sets on Gear. Long or unbounded relationships -continue to use counts, searchable inspection, or expanded detail views. -Public Gear cards use the same rail for compatible Brand Sets and available -Talents so players can identify actual options instead of reading a count. -Each icon retains its accessible name and hover title, and missing artwork uses -the shared placeholder without suppressing the relationship. Relationship -icons are buttons: activating one opens a modal quick view with the shared -database card component and the same facts, tier hierarchy, mechanic rows, and -actions shown in that record's public registry. The overlay traps keyboard -focus, returns focus to its trigger when closed, and closes through its explicit -close action, backdrop, or Escape key. Do not render an empty Talent rail on -item types that cannot equip Talents. +continue to use counts, searchable inspection, or expanded detail views. Public +Gear cards use the same rail for compatible Brand Sets and available Talents so +players can identify actual options instead of reading a count. Each icon +retains its accessible name and hover title, and missing artwork uses the shared +placeholder without suppressing the relationship. Relationship icons are +buttons: activating one opens a modal quick view with the shared database card +component and the same facts, tier hierarchy, mechanic rows, and actions shown +in that record's public registry. The shared card renders a pinned Community +Note when the related record has one; quick views do not load the discussion +thread. The overlay traps keyboard focus, returns focus to its trigger when +closed, and closes through its explicit close action, backdrop, or Escape key. +Do not render an empty Talent rail on item types that cannot equip Talents. ### Public game-database registries -The public Database is a player-facing reference surface, separate from the -ISAC editing workflow. Its index exposes eight durable categories: Gear, -Weapons, Weapon Mods, Gear Mods, OS Protocols, Talents, Brands, and Skill -Chips. Attributes are implementation vocabulary resolved into the facts and -effects shown on those records; do not promote Attributes to a public -top-level category. +The public Database is a player-facing reference surface, separate from the ISAC +editing workflow. Its index exposes eight durable categories: Gear, Weapons, +Weapon Mods, Gear Mods, OS Protocols, Talents, Brands, and Skill Chips. +Attributes are implementation vocabulary resolved into the facts and effects +shown on those records; do not promote Attributes to a public top-level +category. Each index category is a fully interactive square registry card with its full -player-facing category name as the first and strongest element. Record count -and availability are supporting metadata in the footer; operational states -such as “active registry” or “intake” must never compete with the category -identity. Use the larger display-weight category name with a short orange -identity rail; render any adjacent count in muted telemetry styling. Cards -retain a concise scope statement, visible hover/focus state, and pressed -feedback. A category page uses the shared compact category rail, -live name search, pagination, and a responsive card grid. Record cards use the -same hierarchy: +player-facing category name as the first and strongest element. Record count and +availability are supporting metadata in the footer; operational states such as +“active registry” or “intake” must never compete with the category identity. Use +the larger display-weight category name with a short orange identity rail; +render any adjacent count in muted telemetry styling. Cards retain a concise +scope statement, visible hover/focus state, and pressed feedback. A category +page uses the shared compact category rail, live name search, pagination, and a +responsive card grid. Record cards use the same hierarchy: - a fixed icon cell and compact identity header; - small textual badges for quality, tier, or exceptional state; @@ -1748,36 +1792,80 @@ same hierarchy: - relationship lines and tier descriptions only when applicable. When a record represents an ordered progression rather than independent facts, -use the stacked fact treatment. Brand Set bonuses are shown vertically in -two-, three-, then four-piece order; do not add a redundant “3/3 bonuses” -badge when all three rows are already visible. +use the stacked fact treatment. Brand Set bonuses are shown vertically in two-, +three-, then four-piece order; do not add a redundant “3/3 bonuses” badge when +all three rows are already visible. Weapon cards also use stacked facts. Their level-50 Damage range is the primary -fact: a taller orange-railed row with enlarged values and the Damage Type in -the label. Public damage is calculated from the stored level-1 endpoints using -the same `weaponBaseRange` curve as the builder; never label the configured -base endpoints as level-50 values. Weapon Talent 1 and Talent 2 pools use -separate clickable icon rails and the standard Talent quick view. +fact: a taller orange-railed row with enlarged values and the Damage Type in the +label. Public damage is calculated from the stored level-1 endpoints using the +same `weaponBaseRange` curve as the builder; never label the configured base +endpoints as level-50 values. Weapon Talent 1 and Talent 2 pools use separate +clickable icon rails and the standard Talent quick view. Record cards may end with contextual actions that connect related public -registries. Actions use the compact orange tactical-button treatment and -encode their destination filter in the URL. Brand cards provide a “find Gear” -action that opens the Gear registry with that Brand selected; the same action -must remain available in Brand quick views. +registries. Actions use the compact orange tactical-button treatment and encode +their destination filter in the URL. Brand cards provide a “find Gear” action +that opens the Gear registry with that Brand selected; the same action must +remain available in Brand quick views. -Cards display canonical active data only. Attribute references must be -resolved to readable names, values, and units instead of exposing IDs or raw -JSON. Missing uploaded art uses the standard missing-item placeholder. -Desktop layouts use three record columns, intermediate layouts use two, and -narrow layouts use one. The eight-category rail reduces from eight to four to -two columns. Preserve keyboard focus, pressed feedback, reduced-motion -behavior, and readable textual state in every layout. +Discussion and correction controls form a separate utility group at the footer's +trailing edge. These icon controls use neutral surfaces and borders at rest so +players do not mistake them for quality badges or primary actions. The +discussion control shows the visible-message count when the count is nonzero. +Both controls require accessible names and visible hover and focus states. -Category-specific discovery uses the shared `DatabaseDiscoveryConsole`. It -joins the tactical filter matrix, live registry search, result range, -pagination, and page-size controls inside one connected instrument; do not -stack separate bordered filter and pagination bars. Categories without facets -retain the same console with search and pagination alone. +Opening a discussion displays a routed modal with the complete item card above +the thread. The modal keeps the record and conversation in one scroll region, +traps keyboard focus, closes through Escape or the backdrop, and fills the +viewport on narrow screens. + +### Public comment threads + +Use the Agent profile thread as the standard presentation for public comments. +Root comments sit directly on the page or modal surface without a card border, +fill, or shadow. Align comment bodies, actions, reply composers, and removal +tombstones with the author identity. Nested replies add one thin structural rail +and a small left inset; do not wrap replies in another panel. Limit visual +indentation on narrow screens while preserving the logical thread depth. + +Pinned, removed, pending, and moderation states use explicit text. A pinned +label may use Signal Orange, but it must not add a border or background around +the whole comment. Removal tombstones retain reply context without recreating +the missing comment as a filled card. + +Database Community Oversight and Agent profile owners may pin one visible root +comment on their respective threads. Render the pinned conversation first and +keep its replies attached in reading order. Place pin and unpin controls in the +comment overflow menu. + +Pinned Community Note previews on database cards use the muted Armor palette +instead of action orange. Render their sanitized Markdown with the shared +`MarkdownContent` component so links, lists, paragraphs, and emphasis match the +full discussion message. + +Each visible comment places `REPLY` and a low-chrome `...` overflow trigger in +the action row. The menu contains follow and report actions for signed-in Agents +who may use them, author or Community Oversight removal, and Community Oversight +pinning where the target supports a pinned note. Leaf removals delete the +message. A removed message with surviving replies becomes an unboxed tombstone +that identifies whether the poster or Community Oversight removed it. Scrollable +discussion modals reserve enough space after the final message for the complete +overflow menu to open above the scroll boundary. + +Cards display canonical active data only. Attribute references must be resolved +to readable names, values, and units instead of exposing IDs or raw JSON. +Missing uploaded art uses the standard missing-item placeholder. Desktop layouts +use three record columns, intermediate layouts use two, and narrow layouts use +one. The eight-category rail reduces from eight to four to two columns. Preserve +keyboard focus, pressed feedback, reduced-motion behavior, and readable textual +state in every layout. + +Category-specific discovery uses the shared `DatabaseDiscoveryConsole`. It joins +the tactical filter matrix, live registry search, result range, pagination, and +page-size controls inside one connected instrument; do not stack separate +bordered filter and pagination bars. Categories without facets retain the same +console with search and pagination alone. The matrix is a disclosure inside that console. It starts expanded on desktop and collapsed on viewports at or below 700px when no filters are active. Active @@ -1788,105 +1876,106 @@ user's manual disclosure choice in session storage rather than carrying it between unrelated browser sessions. Filters apply immediately, remain encoded in the URL, reset pagination, and -compose with name search. Multiple selections inside one facet are inclusive (`OR`); -active facet groups combine restrictively (`AND`). For example, selecting two -Brands and four Talents returns Gear matching any selected Brand and any -selected Talent. Never implement multi-select facets as an implicit match-all -within the same group. +compose with name search. Multiple selections inside one facet are inclusive +(`OR`); active facet groups combine restrictively (`AND`). For example, +selecting two Brands and four Talents returns Gear matching any selected Brand +and any selected Talent. Never implement multi-select facets as an implicit +match-all within the same group. -Use segmented icon choices for bounded visual vocabularies of six or fewer, -such as Gear slots. Use the matching searchable multi-select list for growing -sets such as Brands and Talents. Always expose selected counts, a single clear +Use segmented icon choices for bounded visual vocabularies of six or fewer, such +as Gear slots. Use the matching searchable multi-select list for growing sets +such as Brands and Talents. Always expose selected counts, a single clear action, `aria-pressed` state, keyboard focus, and explicit text explaining the combination rule. At intermediate width, the bounded facet spans the row above two searchable facets; on narrow screens all facet groups stack vertically. -Weapon discovery applies this pattern to weapon-family icons, damage-type -icons, and the searchable pool of compatible Talents. Talent facet rows show -the rendered Tier I description beneath the name. Talent searches match both -fields case-insensitively so mechanical phrases can discover talents even when -the talent name is unknown. Brand facets similarly index the names of all -set-bonus Attributes, allowing players to search by the desired mechanic -without exposing that metadata as extra visual clutter. +Weapon discovery applies this pattern to weapon-family icons, damage-type icons, +and the searchable pool of compatible Talents. Talent facet rows show the +rendered Tier I description beneath the name. Talent searches match both fields +case-insensitively so mechanical phrases can discover talents even when the +talent name is unknown. Brand facets similarly index the names of all set-bonus +Attributes, allowing players to search by the desired mechanic without exposing +that metadata as extra visual clutter. Public database command headers are content-sized and must explicitly override the global 76-pixel site-header rule. Their eyebrow, title, description, count, and index action remain inside the bordered command surface at every scale. Children use `min-width: 0`, long titles may wrap, and category navigation -tracks use zero-minimum grid columns so translated or enlarged text cannot -force horizontal page overflow. +tracks use zero-minimum grid columns so translated or enlarged text cannot force +horizontal page overflow. ### Build persistence and sharing -The builder distinguishes device persistence from account persistence. `SAVE -LOCALLY` writes a working draft to the current browser. Signed-in agents also -receive one account-save action paired with an explicit visibility selector: +The builder distinguishes device persistence from account persistence. +`SAVE LOCALLY` writes a working draft to the current browser. Signed-in agents +also receive one account-save action paired with an explicit visibility +selector: - `PRIVATE` is owner-only. - `UNLISTED` is accessible by direct link but excluded from discovery. - `PUBLIC` is accessible by direct link and included in the public archive. Keep visibility adjacent to the account-save action and explain its selected -state in plain language. Show a share-page action only after a non-private -build has been saved. Public and unlisted build pages are read-only tactical -summaries; owner editing returns to the builder. On narrow layouts, -persistence controls and shared-loadout columns stack vertically. +state in plain language. Show a share-page action only after a non-private build +has been saved. Public and unlisted build pages are read-only tactical +summaries; owner editing returns to the builder. On narrow layouts, persistence +controls and shared-loadout columns stack vertically. ### Tiered compatibility editors -When compatibility depends on two independent dimensions, keep those -dimensions explicit rather than presenting a flattened list. Weapon -definitions configure one shared set of five Weapon Mod slots for T1 and T2. -T0 uses the same set minus one explicitly selected slot. A T2-native weapon -shows only its applicable shared-slot control, and tiers below the native tier -remain unavailable. Operators may save an unverified shared set or T0 exclusion -when they cannot inspect the weapon in-game. Mark an active weapon `CONFIG -REQUIRED` until every available tier has its full slot count, and keep verified -assignments editable. Reject unknown slot types and counts above the tier limit. -Weapon Mod definitions then configure one slot plus one or more compatible -weapon categories. The UI and validation must require both dimensions to match -once the weapon definition is complete. +When compatibility depends on two independent dimensions, keep those dimensions +explicit rather than presenting a flattened list. Weapon definitions configure +one shared set of five Weapon Mod slots for T1 and T2. T0 uses the same set +minus one explicitly selected slot. A T2-native weapon shows only its applicable +shared-slot control, and tiers below the native tier remain unavailable. +Operators may save an unverified shared set or T0 exclusion when they cannot +inspect the weapon in-game. Mark an active weapon `CONFIG REQUIRED` until every +available tier has its full slot count, and keep verified assignments editable. +Reject unknown slot types and counts above the tier limit. Weapon Mod +definitions then configure one slot plus one or more compatible weapon +categories. The UI and validation must require both dimensions to match once the +weapon definition is complete. Weapon Mod definitions use the shared segmented-choice control for the bounded eight-slot vocabulary, with a full-width row on desktop and two columns on -narrow screens. Weapon category compatibility uses the same control in -checkbox mode with weapon icons and retained accessible names. Show configured -versus required slot counts in text. Disabled tiers remain visibly unavailable -and must not submit slot assignments. +narrow screens. Weapon category compatibility uses the same control in checkbox +mode with weapon icons and retained accessible names. Show configured versus +required slot counts in text. Disabled tiers remain visibly unavailable and must +not submit slot assignments. -Quality-scaled registry editors use a compact matrix when the same numeric -shape repeats across all four quality levels. Apply global quality colors only -to the quality labels, retain textual quality names, and keep numeric fields -neutral. On narrow screens the matrix may scroll horizontally while its -surrounding editor and previews stack. +Quality-scaled registry editors use a compact matrix when the same numeric shape +repeats across all four quality levels. Apply global quality colors only to the +quality labels, retain textual quality names, and keep numeric fields neutral. +On narrow screens the matrix may scroll horizontally while its surrounding +editor and previews stack. When two item slots draw from the same canonical option pool, edit that pool once rather than rendering two duplicated selectors. Weapon Mods use a -repeatable secondary-attribute row: one searchable attribute reference plus -four quality range pairs. Show that the builder selects two distinct options -from the pool. Primary core type is likewise builder-selected and is displayed -as the three always-available core icons rather than a persisted ISAC choice. +repeatable secondary-attribute row: one searchable attribute reference plus four +quality range pairs. Show that the builder selects two distinct options from the +pool. Primary core type is likewise builder-selected and is displayed as the +three always-available core icons rather than a persisted ISAC choice. -Exotic Weapon Mod status is a separate single-icon toggle using the exotic -icon and the standard pressed, focus-visible, and selected states. Selecting -it must switch the editor immediately, before a weapon is assigned. Exotic is -its own unique quality: reveal only the required exotic-weapon assignment, -remove weapon-category and regular-quality controls, and replace random ranges -with fixed level-1 and level-20 attribute values. Keep an explicit Exotic -Quality state panel visible so the change in data semantics is unmistakable. +Exotic Weapon Mod status is a separate single-icon toggle using the exotic icon +and the standard pressed, focus-visible, and selected states. Selecting it must +switch the editor immediately, before a weapon is assigned. Exotic is its own +unique quality: reveal only the required exotic-weapon assignment, remove +weapon-category and regular-quality controls, and replace random ranges with +fixed level-1 and level-20 attribute values. Keep an explicit Exotic Quality +state panel visible so the change in data semantics is unmistakable. ### Gear Mod registry Gear Mods reuse the compact quality matrix, level preview, and pooled secondary-attribute rows established by Weapon Mods. Do not render exotic, weapon-category, slot-type, or Mod Effect controls in this simpler editor. -Present the five primary options as an always-visible informational band -because the actual primary is selected on the build instance, not persisted on -the canonical definition. +Present the five primary options as an always-visible informational band because +the actual primary is selected on the build instance, not persisted on the +canonical definition. -Use explicit textual tier capacity wherever Gear tier is summarized: `1 MOD -SLOT` for T0 and `2 MOD SLOTS` for T1/T2. On narrow screens the identity band -and preview controls stack, while repeated quality data may scroll rather than -compressing numeric controls below usable sizes. +Use explicit textual tier capacity wherever Gear tier is summarized: +`1 MOD SLOT` for T0 and `2 MOD SLOTS` for T1/T2. On narrow screens the identity +band and preview controls stack, while repeated quality data may scroll rather +than compressing numeric controls below usable sizes. ### Deferred game-data icons @@ -1899,8 +1988,8 @@ Application code references that asset through the shared `MISSING_ITEM_ICON_URL` constant and renders it through `ItemIcon`; do not duplicate its path or introduce module-specific missing-icon artwork. -Treat the placeholder as an explicit incomplete-data state: keep its subdued -SHD styling and useful alternative text where the surrounding item name is not +Treat the placeholder as an explicit incomplete-data state: keep its subdued SHD +styling and useful alternative text where the surrounding item name is not already available. Do not persist placeholder metadata or copy the placeholder into per-item storage. A later contributor replaces it through the normal icon upload proposal, preserving the canonical record and audit history. The upload @@ -1916,41 +2005,40 @@ smaller damage-type icon in a dedicated top-right status cluster; do not interrupt the readable `category // damage type` metadata with icons. Reserve the emphasized first fact for the weapon's damage range, pair it with the canonical damage-type icon, and render the calculated level as a subdued -annotation rather than part of the primary label. Public level-50 damage must -be calculated from the stored level-1 endpoints with `weaponBaseRange`; never +annotation rather than part of the primary label. Public level-50 damage must be +calculated from the stored level-1 endpoints with `weaponBaseRange`; never relabel raw source values. Represent bounded handling values such as Accuracy and Stability with compact horizontal meters. Always retain the numeric value and an accessible `value out of 100` label so the graphic does not carry meaning alone. Use the stable sequence `label → meter → exact value`, with the exact number aligned in -a dedicated column to the right of the graphic. Clamp display values to -`0–100`, use orange only for the filled measure, and keep the unfilled track -neutral. Stack meter labels and tracks on narrow cards rather than shrinking -them below a reliably readable width. +a dedicated column to the right of the graphic. Clamp display values to `0–100`, +use orange only for the filled measure, and keep the unfilled track neutral. +Stack meter labels and tracks on narrow cards rather than shrinking them below a +reliably readable width. -Talent registry cards treat the two tier descriptions as their primary -content; never replace them with configured/missing summary facts. Render Tier -I and Tier II as distinct structured data blocks, then place mechanics metadata -below both descriptions. Use 16px semibold sans-serif text for tier effects so -players can scan them before supporting facts. Do not use the serif, -quote-like prose treatment. Use compact fixed tier labels and engineered -borders. Tier I is the neutral baseline; Tier II uses the orange rail, label, -and subtly elevated surface to communicate that it is the improved version. -Render duration and stack limits as detail rows with activation and cooldown -data. Keep calculation effects out of public Talent cards because the tier -descriptions already state the player-facing values. Omit a stack limit of one -because a single active instance is the default. The top-right header status -uses the applicable Backpack or Body Armor icon for gear talents and the SMG -icon as the generic Weapon Talent symbol. Weapon Talent headers pair that icon -with an explicit `1 · TOP` or `2 · BOTTOM` status badge; never rely on the slot -number alone. +Talent registry cards treat the two tier descriptions as their primary content; +never replace them with configured/missing summary facts. Render Tier I and Tier +II as distinct structured data blocks, then place mechanics metadata below both +descriptions. Use 16px semibold sans-serif text for tier effects so players can +scan them before supporting facts. Do not use the serif, quote-like prose +treatment. Use compact fixed tier labels and engineered borders. Tier I is the +neutral baseline; Tier II uses the orange rail, label, and subtly elevated +surface to communicate that it is the improved version. Render duration and +stack limits as detail rows with activation and cooldown data. Keep calculation +effects out of public Talent cards because the tier descriptions already state +the player-facing values. Omit a stack limit of one because a single active +instance is the default. The top-right header status uses the applicable +Backpack or Body Armor icon for gear talents and the SMG icon as the generic +Weapon Talent symbol. Weapon Talent headers pair that icon with an explicit +`1 · TOP` or `2 · BOTTOM` status badge; never rely on the slot number alone. End each Talent card with a compatibility action. Gear Talent actions navigate to the Gear registry with the Talent filter selected. Weapon Talent actions -navigate to the Weapons registry with the Talent Pool filter selected. Keep -this action in shared cards and relationship quick views so both contexts use -the same destination and label. +navigate to the Weapons registry with the Talent Pool filter selected. Keep this +action in shared cards and relationship quick views so both contexts use the +same destination and label. When a public registry needs only one small bounded filter vocabulary, use the compact facet-panel form: one full-width group with all options visible in a @@ -1960,11 +2048,11 @@ when presenting numbered Weapon Talent slots to users. The filter remains additive within the group and wraps to two columns on narrow screens; do not introduce search or dropdown controls for this four-option set. -Notification delivery preferences are a temporary profile control surface. -After a successful save, close the interface and return focus to the inbox -context. Consume a unique server result token only once so later actions or -reopening the panel do not close it again. Validation and server errors keep -the interface open with the submitted controls available for correction. +Notification delivery preferences are a temporary profile control surface. After +a successful save, close the interface and return focus to the inbox context. +Consume a unique server result token only once so later actions or reopening the +panel do not close it again. Validation and server errors keep the interface +open with the submitted controls available for correction. ### Global toast feedback @@ -2003,16 +2091,16 @@ remembered category state. Use the compact command-status strip for persistent system, access, ISAC node, and CI build number context. Add the data-mode cell only in the Data Management context defined above. Keep each label above its value so the visible cells -share one alignment. Text must communicate every state without relying on -color. Registry search remains available for a single page of results, while -page navigation and row-count controls appear only when the result set spans -multiple pages. This keeps expert lookup fast without presenting inactive -controls to contributors and first-time Oversight operators. +share one alignment. Text must communicate every state without relying on color. +Registry search remains available for a single page of results, while page +navigation and row-count controls appear only when the result set spans multiple +pages. This keeps expert lookup fast without presenting inactive controls to +contributors and first-time Oversight operators. Sidebar category headers are real buttons with `aria-expanded` and controlled navigation regions. Search fields have visible or accessible labels, keyboard -focus uses Signal Orange, and collapsed state must remain understandable -without motion. +focus uses Signal Orange, and collapsed state must remain understandable without +motion. ### Managed image library @@ -2027,23 +2115,24 @@ Image intake uses the shared picker, drop-zone, and clipboard control. The canvas. When the source has no usable transparency, the server requests preview-resolution background removal before local normalization and reports configuration, credit, rate-limit, foreground-detection, or network failures -with a specific recovery. Its dependent `Force white` option converts visible pixels to the -canonical neutral icon color while preserving alpha. Both controls state their -processing consequence in text, expose checked and disabled states, and remain -keyboard accessible. Metadata edits preserve the stable asset identity, close -after a successful save, and use the shared toast and editor lifecycle. +with a specific recovery. Its dependent `Force white` option converts visible +pixels to the canonical neutral icon color while preserving alpha. Both controls +state their processing consequence in text, expose checked and disabled states, +and remain keyboard accessible. Metadata edits preserve the stable asset +identity, close after a successful save, and use the shared toast and editor +lifecycle. Use controlled assignment categories for broad placement and free-form, normalized tags for discovery. Names are unique and human-readable. Search -covers names, categories, and tags. On narrow screens, processing controls -stack vertically and cards reduce to one column so previews and actions retain -usable sizes. +covers names, categories, and tags. On narrow screens, processing controls stack +vertically and cards reduce to one column so previews and actions retain usable +sizes. ### ISAC mutation consequence Every writable game-data module states what Save will do before the operator -opens an editor, and the statement remains visible while the editor is open. -The message is derived from effective permissions rather than copied into each +opens an editor, and the statement remains visible while the editor is open. The +message is derived from effective permissions rather than copied into each module: - `OVERSIGHT REVIEW` means saving creates a proposal and does not change live @@ -2061,50 +2150,49 @@ desktop strip so the consequence sentence never runs beneath a button. ### Public database index -The public Database is a lookup surface first and a contribution gateway -second. Use a compact angular command band rather than a cinematic display -hero: keep the archive identity, state how many registries are online, state the -record total, and present contribution as a bordered secondary action. +The public Database is a lookup surface first and a contribution gateway second. +Use a compact angular command band rather than a cinematic display hero: keep +the archive identity, state how many registries are online, state the record +total, and present contribution as a bordered secondary action. Place one discovery bar below the command band. Its label gives users two equal paths: select a registry or search all data. Its search field searches across -all public registry names and useful -descriptive data such as Talent text, Damage Types, item slots, qualities, and -Brand bonus Attributes. Do not stack a separate registry-heading bar above or -below it. Results name both the matching record and its registry, then link into -that registry with the record name applied as its local search. Keep the search -and results dense, keyboard accessible, responsive, and URL-addressable. +all public registry names and useful descriptive data such as Talent text, +Damage Types, item slots, qualities, and Brand bonus Attributes. Do not stack a +separate registry-heading bar above or below it. Results name both the matching +record and its registry, then link into that registry with the record name +applied as its local search. Keep the search and results dense, keyboard +accessible, responsive, and URL-addressable. Registry selection begins immediately after the shared discovery bar. Do not place stewardship, status, promotional, or redundant instructional panels -between it and the registry grid. At wide desktop widths, eight registries form a -four-by-two matrix; the grid reduces to two columns and then one as available +between it and the registry grid. At wide desktop widths, eight registries form +a four-by-two matrix; the grid reduces to two columns and then one as available space narrows. Registry cards lead with complete category names. Counts and availability remain quiet footer metadata, and internal abbreviations or workflow-centric states are not exposed as primary public labels. ### ISAC progression-tree composer -Use the Specialization workbench pattern for ordered game systems that depend -on hierarchy and spatial placement. Keep the registry, selected parent identity, +Use the Specialization workbench pattern for ordered game systems that depend on +hierarchy and spatial placement. Keep the registry, selected parent identity, child navigation, readiness telemetry, and visual tree in one operational surface. Editors open above the registry and follow the standard ISAC lifecycle. Use the workbench for navigation and context. The tree uses a stable 12-column track per row. Row and column are explicit -numeric fields so placement is keyboard operable, auditable, and portable; -never make drag-and-drop the only authoring method. On narrow screens, retain -the geometry in a horizontally scrollable canvas instead of squeezing nodes -until their names and state are unreadable. Controls outside the canvas stack -and maintain at least a 44-pixel touch target. +numeric fields so placement is keyboard operable, auditable, and portable; never +make drag-and-drop the only authoring method. On narrow screens, retain the +geometry in a horizontally scrollable canvas instead of squeezing nodes until +their names and state are unreadable. Controls outside the canvas stack and +maintain at least a 44-pixel touch target. Each row header states its derived gate in plain language. Specialization gates count all available Rank 1 and Rank 2 unlocks in preceding rows, including the -two immediately available ability nodes displayed in the first row. Do not -store or manually edit a second gate value that could diverge. Free nodes use -the orange identity rail plus the explicit `1 / 1 · FREE` state; pending, -archived, incomplete, and builder-ready states must remain textual as well as -colored. +two immediately available ability nodes displayed in the first row. Do not store +or manually edit a second gate value that could diverge. Free nodes use the +orange identity rail plus the explicit `1 / 1 · FREE` state; pending, archived, +incomplete, and builder-ready states must remain textual as well as colored. Show allocation scope as first-class telemetry. A specialization unlocks once; each focus receives its own full point allocation. Show the point total and @@ -2124,8 +2212,8 @@ Use `StructuredEffectEditor` whenever an ISAC record needs an ordered set of calculation-ready effects. It provides Attribute, Ability when applicable, and Custom targets; a numeric value; and the shared bounded unit vocabulary. Keep the target searchable, show all bounded type and unit choices with segmented -controls, and preserve explicit custom labels for mechanics that calculations -do not yet support. Oversight previews must render effect and rank summaries as +controls, and preserve explicit custom labels for mechanics that calculations do +not yet support. Oversight previews must render effect and rank summaries as structured cards. Serialized JSON belongs only in the expandable raw-evidence panel. @@ -2155,8 +2243,8 @@ control, or shared Trigger lifecycle. Each source supplies the value columns required by its progression: - Standard Talents show Tier I and Tier II. Exotic Talents show Tier I and an - explicit Tier II documentation state; the editor adds the Tier II value - column only when contributors select `DOCUMENTED`. + explicit Tier II documentation state; the editor adds the Tier II value column + only when contributors select `DOCUMENTED`. - OS Protocol Talents show a base value and may scale an outcome through the protocol's zero-to-five upgrade expression. - Skill Chip Talents show minimum and maximum values. @@ -2173,43 +2261,41 @@ Talent editors place that surface inside the shared `modeling-disclosure` pattern. New `CAPTURED ONLY` Talents start with an empty program and a collapsed `COMBAT MODELING` summary so authors can record identity, image, and source description first. Existing modeled Talents open the disclosure when edited. -Mechanic validation errors open it and keep the relevant error text visible. -The summary reports `CAPTURE ONLY · MODEL LATER` or the configured branch count -in text. Its native disclosure semantics preserve keyboard and assistive -technology support; mobile targets remain at least 44 pixels tall. +Mechanic validation errors open it and keep the relevant error text visible. The +summary reports `CAPTURE ONLY · MODEL LATER` or the configured branch count in +text. Its native disclosure semantics preserve keyboard and assistive technology +support; mobile targets remain at least 44 pixels tall. The direct-event picker reads the 28 normalized events from the code-defined event registry. Use searchable single-assignment because the vocabulary exceeds -the compact segmented-choice limit. Each branch presents its event, match -policy and parameters, optional cooldown, condition, state mutations, and -outcomes. The branch condition offers `ALWAYS`, `HEALTH BAND`, `EVENT FIELD`, -and `STATE THRESHOLD` for manual authoring. When the selected event publishes -field metadata, event-field conditions derive their available paths and scalar -types from that metadata. Events without a bounded field contract retain the -explicit field-path and value-type controls. Event-field conditions expose the -comparison operator and typed value. State thresholds -compare an agent-scoped pool with a numeric value. Health bands expose an -optional inclusive lower Health percentage and a required exclusive upper -percentage; helper text states how exact thresholds resolve. Predicates outside -these manual patterns remain preserved, display `ADVANCED`, and stay visible in -the generated script. State rows expose -label, agent or target scope, initial value, bounds, -best-case value, and optional expiry. A state change that writes a target pool -uses a `STATE OWNER` segmented control with `EVENT TARGET` and `EVENT ACTOR`; -the selected owner remains visible in the generated script. Outcome rows expose -kind, target scope, Attribute or resource when applicable, value basis, state -scaling, phase, and every source-specific value column. The Value Model control -supports static values, live Attribute totals, percentage-normalized Attribute -values, event fields, reducer output, source-defined scenario controls, and OS -Protocol upgrade scaling where the source permits it. Selecting either -Attribute model uses the shared searchable single-assignment pattern and -displays the Attribute's unit. Label the direct model `LIVE ATTRIBUTE`. Label -the percentage-normalized model `ATTRIBUTE ÷ 100` so authors can see the -transform applied to the stored value. -Restoration outcomes also expose an -`INSTANT` or `CONTINUOUS` segmented choice. Continuous delivery reveals one -required duration field and describes the configured value as the total -delivered across that duration; switching back to instant clears the duration. +the compact segmented-choice limit. Each branch presents its event, match policy +and parameters, optional cooldown, condition, state mutations, and outcomes. The +branch condition offers `ALWAYS`, `HEALTH BAND`, `EVENT FIELD`, and +`STATE THRESHOLD` for manual authoring. When the selected event publishes field +metadata, event-field conditions derive their available paths and scalar types +from that metadata. Events without a bounded field contract retain the explicit +field-path and value-type controls. Event-field conditions expose the comparison +operator and typed value. State thresholds compare an agent-scoped pool with a +numeric value. Health bands expose an optional inclusive lower Health percentage +and a required exclusive upper percentage; helper text states how exact +thresholds resolve. Predicates outside these manual patterns remain preserved, +display `ADVANCED`, and stay visible in the generated script. State rows expose +label, agent or target scope, initial value, bounds, best-case value, and +optional expiry. A state change that writes a target pool uses a `STATE OWNER` +segmented control with `EVENT TARGET` and `EVENT ACTOR`; the selected owner +remains visible in the generated script. Outcome rows expose kind, target scope, +Attribute or resource when applicable, value basis, state scaling, phase, and +every source-specific value column. The Value Model control supports static +values, live Attribute totals, percentage-normalized Attribute values, event +fields, reducer output, source-defined scenario controls, and OS Protocol +upgrade scaling where the source permits it. Selecting either Attribute model +uses the shared searchable single-assignment pattern and displays the +Attribute's unit. Label the direct model `LIVE ATTRIBUTE`. Label the +percentage-normalized model `ATTRIBUTE ÷ 100` so authors can see the transform +applied to the stored value. Restoration outcomes also expose an `INSTANT` or +`CONTINUOUS` segmented choice. Continuous delivery reveals one required duration +field and describes the configured value as the total delivered across that +duration; switching back to instant clears the duration. Action Family recipes are optional authoring starters. Present the recipe catalog as a searchable assignment with only the parameters required by the @@ -2221,11 +2307,11 @@ an inline confirmation that states all existing branches will be discarded. Builder Controls define source-specific advanced scenario assumptions. Authors choose toggle, slider, or numeric input and provide a label, Agent guidance, default value, best-case value, and numeric bounds when applicable. Group these -controls behind the builder's advanced scenario surface. Maximum Potential -uses best-case values. Custom Scenario uses saved Agent values. A required -cadence or environmental value with no supplied assumption keeps the mechanic -out of the calculation and displays `REQUIRES ASSUMPTIONS`; the editor and -runtime never invent a cadence. +controls behind the builder's advanced scenario surface. Maximum Potential uses +best-case values. Custom Scenario uses saved Agent values. A required cadence or +environmental value with no supplied assumption keeps the mechanic out of the +calculation and displays `REQUIRES ASSUMPTIONS`; the editor and runtime never +invent a cadence. Use `--shd-model-surface` and `--shd-model-border` for mechanic programs, calculation revisions, and field calibration. The blue information rail marks @@ -2235,10 +2321,10 @@ and per-stack values in text so the model remains legible without color. At narrow widths, stack panels, branch controls, state rows, variant columns, and outcomes without changing their order. Keep add, remove, replace, and help -controls at least 44 pixels tall. Every disclosure and confirmation exposes -its state to assistive technology. Focus remains in the active field when the -generated script refreshes, and reduced-motion preferences remove expansion -and replacement transitions. +controls at least 44 pixels tall. Every disclosure and confirmation exposes its +state to assistive technology. Focus remains in the active field when the +generated script refreshes, and reduced-motion preferences remove expansion and +replacement transitions. The recipe starter opens with no recipe selected. Keep its count, duration, distance, and Health-band parameters hidden until the author chooses a recipe. @@ -2262,11 +2348,11 @@ An author submits the exact in-game mechanic text and optional context. Keep the source text editable during clarification and preview. Use the standard pending button treatment while a request runs and block duplicate submissions. -Agents without `ai.mechanic.generate` still see the collapsed panel. Disable -its request control, label the state `PERMISSION REQUIRED`, and direct -the agent to request permission from an administrator. The disabled state must -not expose API configuration, internal prompt content, model controls, or a -working form submission. +Agents without `ai.mechanic.generate` still see the collapsed panel. Disable its +request control, label the state `PERMISSION REQUIRED`, and direct the agent to +request permission from an administrator. The disabled state must not expose API +configuration, internal prompt content, model controls, or a working form +submission. The panel presents five explicit states: @@ -2277,25 +2363,25 @@ The panel presents five explicit states: bounded answer control. The author may answer, revise the source text, or return to manual authoring. - `CAPABILITY GAP` names the unsupported behavior and displays the developer - handoff, supported approximation, and optional workaround in separate - labeled blocks. Do not present a capability gap as validation failure. + handoff, supported approximation, and optional workaround in separate labeled + blocks. Do not present a capability gap as validation failure. - `DRAFT PREVIEW` shows assumptions, warnings, readiness, and the generated script before an explicit `APPLY TO EDITOR` action. -The capability-gap state offers `COPY DEVELOPER PROMPT` and `SUBMIT TO ADMIN -TEAM` beside `REVISE SOURCE WORDING`. The copied prompt includes the exact -analyzed wording, clarification answers, gap evidence, and implementation -acceptance criteria. Submission sends one mandatory ISAC notification to each -active full administrator and records the report in the `ai_assistance` audit -history. Disable duplicate submission for the current assistant request. -Clipboard or network failures keep the handoff visible and show a specific -recovery message. Neither action changes the mechanic editor or saves the -parent record. +The capability-gap state offers `COPY DEVELOPER PROMPT` and +`SUBMIT TO ADMIN TEAM` beside `REVISE SOURCE WORDING`. The copied prompt +includes the exact analyzed wording, clarification answers, gap evidence, and +implementation acceptance criteria. Submission sends one mandatory ISAC +notification to each active full administrator and records the report in the +`ai_assistance` audit history. Disable duplicate submission for the current +assistant request. Clipboard or network failures keep the handoff visible and +show a specific recovery message. Neither action changes the mechanic editor or +saves the parent record. -Applying a draft fills the structured fields in the current unsaved editor. -It does not save, publish, submit a proposal, close the editor, or discard -manual changes without confirmation. Show a concise before-and-after summary -when applying the draft would replace configured branches, pools, controls, or +Applying a draft fills the structured fields in the current unsaved editor. It +does not save, publish, submit a proposal, close the editor, or discard manual +changes without confirmation. Show a concise before-and-after summary when +applying the draft would replace configured branches, pools, controls, or outcomes. The standard ISAC save lifecycle begins only after the author reviews and saves the resulting form. @@ -2310,24 +2396,23 @@ inside the viewport. Focus moves to the first clarification question, the capability-gap heading, or the draft-preview heading when a result arrives. Return focus to the initiating control when the author closes or resets the panel. Announce the result state without reading the complete generated script. -Reduced-motion preferences remove pending, disclosure, and preview -transitions while preserving textual state changes. +Reduced-motion preferences remove pending, disclosure, and preview transitions +while preserving textual state changes. #### Generated mechanic script The shared `PseudoScript` component gives authors and builders a readable transcription of the typed mechanic or combat calculation. The typed event, predicate, reducer, state-pool, outcome, and calculation-stage data remain -authoritative. The interface generates the script from that data; authors do -not edit or execute the displayed text. +authoritative. The interface generates the script from that data; authors do not +edit or execute the displayed text. -Render generated script on `--shd-model-surface` with -`--shd-model-border`, a blue information rail, restrained green syntax -keywords, line numbers, and the shared monospaced typeface. Give the panel an -explicit `GENERATED SCRIPT · READ ONLY` label and show its calculation -readiness in text. Reserve orange for adjacent actions and selected controls. -Do not style the panel as an editable textarea or show a caret, resize handle, -or save action. +Render generated script on `--shd-model-surface` with `--shd-model-border`, a +blue information rail, restrained green syntax keywords, line numbers, and the +shared monospaced typeface. Give the panel an explicit +`GENERATED SCRIPT · READ ONLY` label and show its calculation readiness in text. +Reserve orange for adjacent actions and selected controls. Do not style the +panel as an editable textarea or show a caret, resize handle, or save action. Use semantic code content inside the panel. Treat line numbers as structural decoration and ensure assistive technology reads each statement once. Preserve @@ -2348,12 +2433,12 @@ specific topic. Do not point full-reference actions to collapsed same-page anchors. Help triggers and disclosure summaries use native button semantics, -focus-visible treatment, and explicit expanded or dialog state. Opening a -dialog moves focus to its close control; Escape and the close action dismiss -it and return focus to the trigger. On mobile, the dialog fits the viewport, -scrolls its body, and keeps its close action available. Reduced-motion -preferences remove dialog and disclosure transitions. Syntax color and motion -never carry meaning without text. +focus-visible treatment, and explicit expanded or dialog state. Opening a dialog +moves focus to its close control; Escape and the close action dismiss it and +return focus to the trigger. On mobile, the dialog fits the viewport, scrolls +its body, and keeps its close action available. Reduced-motion preferences +remove dialog and disclosure transitions. Syntax color and motion never carry +meaning without text. #### Mechanic reference surface @@ -2384,11 +2469,11 @@ copy. Search operates on the full active glossary before any result limit and keeps its visible label, clear action, keyboard focus, and textual result count. Section-index links and anchored headings use visible keyboard focus. In-page -navigation must place the target below the application header. At narrow -widths, move the index into document flow above the content and stack reference -sections in one column. Allow connected matrices to scroll within their own -boundaries, and prevent horizontal page overflow. Reduced-motion preferences -remove smooth anchor scrolling and sticky-state transitions. +navigation must place the target below the application header. At narrow widths, +move the index into document flow above the content and stack reference sections +in one column. Allow connected matrices to scroll within their own boundaries, +and prevent horizontal page overflow. Reduced-motion preferences remove smooth +anchor scrolling and sticky-state transitions. #### Builder calculation modes @@ -2402,10 +2487,10 @@ visible labels: - `CUSTOM SCENARIO` exposes encounter assumptions and source-defined advanced controls for a player-defined scenario. -New loadouts select `MAXIMUM POTENTIAL`. The interface labels Maximum results -as a modeled ceiling so players do not read them as typical encounter -performance. Maximum hides granular assumption fields without deleting their -saved values. Returning to Custom restores those values. +New loadouts select `MAXIMUM POTENTIAL`. The interface labels Maximum results as +a modeled ceiling so players do not read them as typical encounter performance. +Maximum hides granular assumption fields without deleting their saved values. +Returning to Custom restores those values. Custom mode gives each unlocked conditional effect three state controls: @@ -2416,18 +2501,18 @@ Custom mode gives each unlocked conditional effect three state controls: Users may override encounter assumptions on one effect without changing the global Custom profile. Open the advanced scenario surface to show only the -toggle, slider, and numeric controls defined by that effect's owning source. -The effect card shows uptime, activation count, and applied stack state beside -the mode. A mechanic that needs an undefined cadence or another missing -scenario input displays `REQUIRES ASSUMPTIONS`; the builder withholds its -calculated contribution until the author or player supplies that input. +toggle, slider, and numeric controls defined by that effect's owning source. The +effect card shows uptime, activation count, and applied stack state beside the +mode. A mechanic that needs an undefined cadence or another missing scenario +input displays `REQUIRES ASSUMPTIONS`; the builder withholds its calculated +contribution until the author or player supplies that input. Maximum and Custom use radio semantics, an explicit selected label, keyboard arrow navigation, and the standard orange selected rail. Results repeat the active calculation mode in text. On mobile, the global mode control and Custom -assumption fields stack into full-width targets of at least 44 pixels. -Mode changes use the standard pressed feedback; reduced-motion preferences -remove transitions without hiding the state change. +assumption fields stack into full-width targets of at least 44 pixels. Mode +changes use the standard pressed feedback; reduced-motion preferences remove +transitions without hiding the state change. Combat result panels show normal, critical, headshot, and critical-headshot values with burst, sustained, and encounter DPS. Conditional stack telemetry @@ -2439,12 +2524,12 @@ required inputs. ### Specialization loadout composer -The builder presents Specialization and Focus as the first loadout decision. -The Focus sets the primary Attribute, progression tree, and three ordered -abilities. Its identity combines Specialization artwork and the primary -Attribute mark. Each ability panel contains three fixed Skill Chip positions. -A chip picker shows definitions assigned to that ability and excludes -definitions assigned elsewhere. +The builder presents Specialization and Focus as the first loadout decision. The +Focus sets the primary Attribute, progression tree, and three ordered abilities. +Its identity combines Specialization artwork and the primary Attribute mark. +Each ability panel contains three fixed Skill Chip positions. A chip picker +shows definitions assigned to that ability and excludes definitions assigned +elsewhere. The focus tree groups nodes by row and uses segmented rank controls. Disabled ranks remain visible with their lock state. The builder permits a row after the @@ -2452,36 +2537,35 @@ user buys every available Rank 1 and Rank 2 in preceding rows and enforces the 49-point limit. Cost telemetry lists Credits and components beside each rank. Each ability panel exposes one compact activation control: Off, On Cooldown, or -Custom Rate. Custom Rate adds uses per minute. Every enabled -ability has its own target count. Skill result cards show the ability name, -activation count, single-target DPS, total-target DPS, and a combined summary. -They state that Skills can critically hit and multi-shot but cannot headshot. +Custom Rate. Custom Rate adds uses per minute. Every enabled ability has its own +target count. Skill result cards show the ability name, activation count, +single-target DPS, total-target DPS, and a combined summary. They state that +Skills can critically hit and multi-shot but cannot headshot. Saved loadouts store focus, node ranks, nine Skill Chips, conditional modes, encounter assumptions, and per-ability activation profiles. The builder accepts -only the current complete structure. Invalid local drafts are discarded; -invalid server records are removed through a backed-up database migration. +only the current complete structure. Invalid local drafts are discarded; invalid +server records are removed through a backed-up database migration. ### Calculation revision controls -Combat rules use immutable active and retired revisions. ISAC editors may -change draft revisions, ordered stages, and Attribute profiles. Activation -requires `calculation.publish`, an explicit review state, and a confirmation -that saved builds will recalculate. Successful activation closes the -confirmation surface and records the revision in the audit history. +Combat rules use immutable active and retired revisions. ISAC editors may change +draft revisions, ordered stages, and Attribute profiles. Activation requires +`calculation.publish`, an explicit review state, and a confirmation that saved +builds will recalculate. Successful activation closes the confirmation surface +and records the revision in the audit history. Keep weapon and Skill Attribute profiles in separate compact registries inside the same revision editor. A Skill profile shows the Attribute, one of the -bounded Skill stages, Add or Multiply, and its weight. Use searchable -assignment for the ten-stage vocabulary and a segmented control for the -two-value operator. Reject duplicate Attribute and stage pairs. Revision -cloning, proposal review, publication evidence, and audit snapshots must -include both profile registries. +bounded Skill stages, Add or Multiply, and its weight. Use searchable assignment +for the ten-stage vocabulary and a segmented control for the two-value operator. +Reject duplicate Attribute and stage pairs. Revision cloning, proposal review, +publication evidence, and audit snapshots must include both profile registries. Calibration cases pair a loadout snapshot with measured hit and timing values. Ready cases require the four hit values, magazine time, and reload time. -Assisted suggestions create a draft revision for review and leave activation -to an authorized operator. Use monospaced text for raw loadout and prediction +Assisted suggestions create a draft revision for review and leave activation to +an authorized operator. Use monospaced text for raw loadout and prediction snapshots; labels, findings, and errors stay in the standard operational typeface. @@ -2489,17 +2573,16 @@ Calculation revision editors include the optional AI drafting panel after the revision identity and before the manual stage and profile controls. Reuse the `READY FOR INPUT`, `DRAFTING`, `CLARIFICATION REQUIRED`, `CAPABILITY GAP`, and `DRAFT PREVIEW` states from AI mechanic drafting. The panel uses calculation -language, identifies the active or edited base revision, and previews changes -to stages, weapon profiles, and Skill profiles. +language, identifies the active or edited base revision, and previews changes to +stages, weapon profiles, and Skill profiles. -Agents without `ai.calculation.generate` see the collapsed -`PERMISSION REQUIRED` state. Agents with the permission may request and apply a -draft, but the panel leaves every manual calculation control available. -Applying a draft fills the open unsaved revision form. It does not save the -revision, activate it, grant `calculation.publish`, or bypass the activation -confirmation. Before applying, show current and proposed counts for stages, -weapon profiles, and Skill profiles beside assumptions, warnings, and the -generated script. +Agents without `ai.calculation.generate` see the collapsed `PERMISSION REQUIRED` +state. Agents with the permission may request and apply a draft, but the panel +leaves every manual calculation control available. Applying a draft fills the +open unsaved revision form. It does not save the revision, activate it, grant +`calculation.publish`, or bypass the activation confirmation. Before applying, +show current and proposed counts for stages, weapon profiles, and Skill profiles +beside assumptions, warnings, and the generated script. Use the same pending, duplicate-request, focus, live-region, mobile stacking, 44-pixel target, and reduced-motion rules as the mechanic assistant. A @@ -2509,16 +2592,17 @@ curve, or runtime behavior and keeps the manual revision editor available. ### Image Library assignment state An ISAC game-data editor registers each uploaded icon in the Image Library. The -library record and editor share one processed file. Its card uses `ASSIGNED -ICON`, names the owning entity type beside the category, and carries searchable -tags for `isac`, `game data`, the module, the entity name, and entity context -such as quality, focus, ability, or Talent category. +library record and editor share one processed file. Its card uses +`ASSIGNED ICON`, names the owning entity type beside the category, and carries +searchable tags for `isac`, `game data`, the module, the entity name, and entity +context such as quality, focus, ability, or Talent category. -Operators may rename, recategorize, and add tags to the library record. Replacing -the icon from its game-data editor keeps that metadata and refreshes the context -tags and file facts. Removing the source icon removes its linked library record. -Each library card offers `DOWNLOAD`, `EDIT`, and `DELETE` in a connected action -rail. `DELETE` opens a focused confirmation that names the asset and states that -the change is permanent. For assigned icons, the confirmation names the owning -entity type. ISAC clears that source icon assignment before it removes the -shared file. General library uploads retain the `ICON` or `IMAGE` state. +Operators may rename, recategorize, and add tags to the library record. +Replacing the icon from its game-data editor keeps that metadata and refreshes +the context tags and file facts. Removing the source icon removes its linked +library record. Each library card offers `DOWNLOAD`, `EDIT`, and `DELETE` in a +connected action rail. `DELETE` opens a focused confirmation that names the +asset and states that the change is permanent. For assigned icons, the +confirmation names the owning entity type. ISAC clears that source icon +assignment before it removes the shared file. General library uploads retain the +`ICON` or `IMAGE` state. diff --git a/docs/architecture.md b/docs/architecture.md index ead085a..125af10 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -5,24 +5,28 @@ The team builds the project in vertical slices. ## Application stack - SvelteKit 2 / Svelte 5 with TypeScript -- Embedded Turso Database via `@tursodatabase/database` for durable application data -- Auth.js-compatible server authentication with Discord OAuth and credentials providers -- Argon2id password hashing, server-side sessions, CSRF protection, and role checks +- Embedded Turso Database via `@tursodatabase/database` for durable application + data +- Auth.js-compatible server authentication with Discord OAuth and credentials + providers +- Argon2id password hashing, server-side sessions, CSRF protection, and role + checks - Browser `localStorage` only for anonymous build drafts ## Product boundaries - Public: browse, sort, and view published builds and guides. - Anonymous: create and edit local drafts. -- Authenticated: publish, rate, comment, favorite, copy, and manage owned builds. +- Authenticated: publish, rate, comment, favorite, copy, and manage owned + builds. - Editor: manage builder source data. - Admin: manage users, roles, source data, and audit history. Server code checks authorization for each mutation. UI visibility has no effect on server authorization. -Weapon and gear range calculations must preserve decimal endpoints -and follow the tested progression rules in +Weapon and gear range calculations must preserve decimal endpoints and follow +the tested progression rules in [`STAT_LEVEL_SCALING.md`](STAT_LEVEL_SCALING.md). Leveling is linear rather than compounded. The presentation layer truncates game-matching integer values. @@ -49,9 +53,9 @@ Builder. Schema migration creates the row with Coming Soon enabled, account creation enabled, and all five sections enabled. The application caches the row for five seconds and clears the process cache after an operator saves changes. -Agents with `site.settings.manage` edit the row from ISAC → System -Configuration → Site Controls. The save action requires an explicit value for -all seven controls and a confirmation token. A successful update records +Agents with `site.settings.manage` edit the row from ISAC → System Configuration +→ Site Controls. The save action requires an explicit value for all seven +controls and a confirmation token. A successful update records `system_configuration / site_access.updated` with the operator, target row, and complete before/after snapshots. @@ -84,22 +88,102 @@ closed. ## Community content moderation -Comments, profile biographies, custom avatars, guides, and build descriptions -share the moderation lifecycle documented in -[`CONTENT_MODERATION.md`](CONTENT_MODERATION.md). Local rules and the configured -provider classify each submission before publication. Clean submissions can -publish without human work. Flagged submissions and fail-closed provider errors -enter the appropriate Oversight queue. +Comments, profile biographies, custom avatars, and Guides share the moderation +lifecycle documented in [`CONTENT_MODERATION.md`](CONTENT_MODERATION.md). Local +rules and the configured provider classify each submission before publication. +Clean submissions can publish without human work. Flagged submissions and +fail-closed provider errors enter the appropriate Oversight queue. Agents may report visible content they do not own. Reports enter Oversight -without hiding the content. Human decisions require a reason and append an -audit event. Long-form edits that need review use immutable pending revisions -so the last approved biography, guide, or description remains public. +without hiding the content. Human decisions require a reason and append an audit +event. Long-form edits that need review use immutable pending revisions so the +last approved biography or Guide remains public. ISAC Site Controls owns provider behavior, content scopes, thresholds, link and PII checks, and allowed or banned terms. API credentials remain in server environment configuration. +## Guides and News articles + +`articles` stores the stable identity, kind, owner, immutable slug, visibility, +and published or pending revision pointers for Guides and News. Authors edit one +mutable `article_drafts` row with optimistic version checks. Publication creates +an immutable `article_revisions` snapshot. Public readers and search load only +the revision named by `articles.published_revision_id`. + +Guides require OpenAI moderation before an unlisted or public revision can +publish. The moderation request includes the title, deck, document text, image +alternative text and captions, cover image, and inline images. A failed provider +request stays in Guide Oversight. Clean revisions may publish automatically; +flagged revisions preserve the previous public revision while Oversight decides. +News bypasses this lifecycle because `news.manage` is the editorial gate. News +comments still use community moderation. + +### Ubisoft News syndication + +The Ubisoft News importer reads the English-language Division Resurgence feed +from Ubisoft Nimbus. It stores each Nimbus item ID as the stable external ID and +publishes only the source title, excerpt, and canonical Ubisoft link. It does +not copy the source article body or images. Imported articles use Ubisoft's +original publication timestamp rather than the import time. + +`external_news_sources` maps each Nimbus ID to one SHD.build article. A SHA-256 +content hash covers the normalized title, excerpt, canonical URL, and source +publication timestamp. An unchanged hash updates `last_seen_at` without creating +a revision. A changed hash creates and publishes an immutable revision. The +importer does not delete or unpublish an article when Nimbus omits it from a +later feed, and it does not restore an article that an editor deleted. The +Newsroom treats source-linked articles as read-only so local edits cannot retain +Ubisoft attribution. The importer defers future-dated items until their source +publication time. + +The seeded, non-public `Ubisoft Official News` service identity owns imported +articles and acts on their audit events. A first import records +`editorial_content / news.imported`; a later content sync records +`editorial_content / news.import_synced`. Audit snapshots identify the provider, +Nimbus ID, canonical URL, source timestamp, and resulting revision. + +The existing bearer-authenticated scheduler endpoint invokes the importer. A +successful run sets the next check one hour later. The importer stores the last +attempt, last success, next check, consecutive failure count, and bounded last +error in provider state. Failures retry after 15 minutes with exponential +backoff capped at six hours. A successful run clears the error and failure +count. + +The article document is a versioned, strictly validated Tiptap JSON envelope. +The application stores no author-supplied HTML and renders accepted nodes with +Svelte components. `article_revision_build_embeds` supports several Builds per +Guide and several Guides per Build. Each relation records a bounded publication +snapshot. Authors may embed any Build they own; another Agent's Build must be +public and becomes unavailable in the Guide if its source stops being public. + +Guide taxonomy combines fixed activity and difficulty terms with active +Specializations, Focuses, and weapon categories. `article_search` stores the +published title, deck, body text, and tags. Weighted token scoring prioritizes +title, deck, tags, then body because the embedded Turso engine does not ship the +FTS5 module. Search rows exist only for current public revisions. + +Registered and anonymous visitors may rate a Guide from 1 through 5. Anonymous +ratings and Guide reports use a random HTTP-only browser token; the database +stores only its domain-separated hash. Repeated anonymous activity requires a +Turnstile result. Guide reports reference the exact published revision so a +report against an older revision cannot hide newer work. + +The Builder stores loadout data only. It has no summary or Guide body. An owned +Build page links to `/guides/new?build=`, where the server verifies +ownership and initializes a removable Build block in a new private Guide. + +## Public data corrections and discussion + +Every public database card links to its canonical ISAC record with an exact +`item` ID and offers a moderated Community Discussion. Authenticated Agents can +post notes and replies; Community Oversight may pin one visible comment as the +record's Community Note. A public correction opens the same ISAC editor used by +data operators. Contributor-mode submissions create a proposal rather than +changing canonical data. Contributors can attach an optional bounded rationale; +the rationale is retained with the proposal and visible to Data Oversight, but +does not become part of the canonical action replay. + ## Increment plan 1. Product shell and anonymous local-draft builder. @@ -109,10 +193,11 @@ environment configuration. 5. Publishing, discovery, ratings, comments, favorites, and copy flow. 6. Rich build guides and moderation. 7. Admin editors, validation, audit log, and data import/export. + # Administrator bootstrap -The authentication service promotes the first authenticated user to `admin` if the -database does not contain an administrator. Promotion uses one conditional +The authentication service promotes the first authenticated user to `admin` if +the database does not contain an administrator. Promotion uses one conditional database update so concurrent first sign-ins cannot both become administrators. Once an administrator exists, later sign-ins never alter roles. @@ -134,8 +219,8 @@ npm run db:backup:production ``` The scripts write versioned JSON documents beneath `data/backups/` by default. -Git ignores that directory because backups contain private user data, -including password hashes. +Git ignores that directory because backups contain private user data, including +password hashes. To restore, first create an empty database and apply the schema. Then run: @@ -144,18 +229,18 @@ npm run db:restore -- data/backups/.json ``` The restore operation refuses to import into any database containing records. -This guard prevents accidental merging or overwriting of contributor data. -Stop the application before backup or restore so the embedded database file is -not locked by another process. +This guard prevents accidental merging or overwriting of contributor data. Stop +the application before backup or restore so the embedded database file is not +locked by another process. ## Development database maintenance Treat the development database as persistent data. Stop this project's development server before backup, export, restore, migration, or other work requiring exclusive access. Create and verify a JSON backup, perform the -prepared operation, verify schema state and critical record counts, then -restart and health-check the application. Prefer additive, repeatable -migrations and retain the verified backup if maintenance fails. +prepared operation, verify schema state and critical record counts, then restart +and health-check the application. Prefer additive, repeatable migrations and +retain the verified backup if maintenance fails. Production and staging remain shared persistent environments and retain the deployment, backup, synchronization, and maintenance safeguards documented in @@ -165,22 +250,22 @@ deployment, backup, synchronization, and maintenance safeguards documented in Every successful ISAC mutation appends an event to `audit_log` through the shared server audit utility. Events identify the operator, operational category, -specific action, target entity, timestamp, and useful before/after evidence. -The audit registry is read-only and requires `audit.view`; the seeded Auditor -role grants that permission without granting mutation capabilities. +specific action, target entity, timestamp, and useful before/after evidence. The +audit registry is read-only and requires `audit.view`; the seeded Auditor role +grants that permission without granting mutation capabilities. Audit history is immutable. The server implements “Revert” as a new compensating mutation rather than deletion or modification of the original event: 1. The operator must hold `audit.revert` in addition to any other access. -2. The reversion registry must support the original action and the event must contain a usable - before-snapshot. +2. The reversion registry must support the original action and the event must + contain a usable before-snapshot. 3. The UI requires a second, explicit confirmation. 4. The server reloads the target and compares it with the original after-snapshot, ignoring bookkeeping timestamps. A mismatch indicates newer work and blocks automatic compensation. -5. The server revalidates referenced roles, permissions, and relationships before - restoration. Restoring administrator authority also requires full +5. The server revalidates referenced roles, permissions, and relationships + before restoration. Restoring administrator authority also requires full administrative access. 6. The server restores the previous state and appends an `audit.reverted` event whose target is the original audit-event ID. @@ -189,47 +274,48 @@ mutation rather than deletion or modification of the original event: # Moderated game-data contributions ISAC data access uses `data.view`, `data.propose`, `data.manage`, and -`data.approve`. Data Viewers can inspect registries. Data Contributors can submit -the same editor forms, but the server serializes each request into +`data.approve`. Data Viewers can inspect registries. Data Contributors can +submit the same editor forms, but the server serializes each request into `data_change_proposals` instead of mutating canonical tables. Data Oversight reviews the queue and must confirm approval or rejection. Approval replays the stored form through its original SvelteKit server action. -This preserves canonical validation, relational updates, icon processing, and the -normal game-data audit event. Proposal creation and review decisions also produce -separate `data_moderation` audit events. Failed canonical validation marks a -proposal as failed without changing live data. Pending counts appear alongside -each data registry in ISAC so collaborators can see concurrent suggestions. +This preserves canonical validation, relational updates, icon processing, and +the normal game-data audit event. Proposal creation and review decisions also +produce separate `data_moderation` audit events. Failed canonical validation +marks a proposal as failed without changing live data. Pending counts appear +alongside each data registry in ISAC so collaborators can see concurrent +suggestions. The protected seeded roles are Data Viewer, Data Contributor, Data Editor, and Data Oversight. Data Editor retains direct-write access for trusted operators; Data Oversight combines direct-write access with queue review. New ISAC data -modules must use the shared moderation interception path and audit both the final -mutation and proposal lifecycle. +modules must use the shared moderation interception path and audit both the +final mutation and proposal lifecycle. Mechanic programs remain part of their owning source record. Talent, OS Protocol, Skill Chip, and Specialization proposals carry the complete typed -program with the rest of the source payload. Oversight reviews and approves -that program through the source module's canonical validation path. There is -no standalone Trigger proposal module or pending Trigger dependency. +program with the rest of the source payload. Oversight reviews and approves that +program through the source module's canonical validation path. There is no +standalone Trigger proposal module or pending Trigger dependency. -Each authenticated, active registered agent receives `isac.access`, -`data.view`, and `data.propose` as baseline permissions in the authorization -layer. The authorization layer grants this baseline without a removable role -assignment, so existing users and each account-creation method receive the same -access. Registered agents can inspect live source data and submit -moderated proposals, but cannot mutate canonical records, review the -queue, or access unrelated ISAC operations without additional permissions. -Unsupported destructive identity operations, account deletion, incomplete -snapshots, stale targets, and events whose dependencies no longer exist are -marked non-reversible or rejected. Recovery for those cases requires a -specialized workflow rather than inferred database writes. +Each authenticated, active registered agent receives `isac.access`, `data.view`, +and `data.propose` as baseline permissions in the authorization layer. The +authorization layer grants this baseline without a removable role assignment, so +existing users and each account-creation method receive the same access. +Registered agents can inspect live source data and submit moderated proposals, +but cannot mutate canonical records, review the queue, or access unrelated ISAC +operations without additional permissions. Unsupported destructive identity +operations, account deletion, incomplete snapshots, stale targets, and events +whose dependencies no longer exist are marked non-reversible or rejected. +Recovery for those cases requires a specialized workflow rather than inferred +database writes. # System metrics -ISAC uses `/isac` as its canonical route namespace. The server -redirects legacy `/admin` URLs to their matching `/isac` location so old -notifications and external bookmarks remain usable. +ISAC uses `/isac` as its canonical route namespace. The server redirects legacy +`/admin` URLs to their matching `/isac` location so old notifications and +external bookmarks remain usable. The read-only `/isac/metrics` route requires `metrics.view` and collects a point-in-time process-local health snapshot. Node and operating-system APIs @@ -241,10 +327,10 @@ provide application record volumes. The interface refreshes on demand and may poll every ten seconds while the operator keeps auto-refresh enabled. CPU and event-loop values are deltas from the previous server-side sample, so the first sample and samples separated by -long gaps provide direction rather than a monitoring-grade time series. Production alerting -and historical analysis should use a dedicated external telemetry -system. This surface omits secrets, environment-variable values, -and full database paths. +long gaps provide direction rather than a monitoring-grade time series. +Production alerting and historical analysis should use a dedicated external +telemetry system. This surface omits secrets, environment-variable values, and +full database paths. The same surface reports remove.bg paid credits and free API calls as separate capacity pools. The scheduler reconciles the account balance no more often than @@ -252,8 +338,8 @@ the configured interval, while successful image processing applies the provider's `X-Credits-Charged` value to the persisted estimate. Warning and critical transitions create one alert episode with a unique delivery for each active Administrator. A later critical transition may alert again; repeated -scheduler runs within the same episode do not. Provider errors preserve the -last known balance and never expose the API key or raw response body. +scheduler runs within the same episode do not. Provider errors preserve the last +known balance and never expose the API key or raw response body. # Weapon category bonuses @@ -266,8 +352,8 @@ Weapon records do not own copies of these rows. Both builder catalog loaders attach the matching category profile when they hydrate a weapon. Exotic weapons retain an underlying category and inherit the same profile as a standard weapon of that type. The combat summary preserves the existing equipped-weapon -semantics: each equipped weapon contributes its inherited profile to the -Agent's accumulated Attributes. +semantics: each equipped weapon contributes its inherited profile to the Agent's +accumulated Attributes. ISAC exposes one category-profile editor above the weapon registry. Operators may change the three positive values, while the Attribute identities and order @@ -286,14 +372,14 @@ An exotic weapon references one active exotic talent through `weapons.exotic_talent_id`. The weapon editor lists registered exotic talents and identifies talents assigned to another weapon. Server validation rejects standard talents, inactive talents, and talents already assigned elsewhere. -Changing or clearing a weapon relationship leaves the previous talent active -and available for another assignment. +Changing or clearing a weapon relationship leaves the previous talent active and +available for another assignment. -Tier I and its modeled mechanic are required when a contributor saves an -exotic talent. Tier II may remain undocumented. An undocumented Tier II has no +Tier I and its modeled mechanic are required when a contributor saves an exotic +talent. Tier II may remain undocumented. An undocumented Tier II has no `talent_tiers` row or conditional-effect value variant. A later Talent Registry -edit adds the Tier II projection and mechanic values without changing the -weapon reference. +edit adds the Tier II projection and mechanic values without changing the weapon +reference. # OS Protocol progression @@ -309,22 +395,22 @@ attribute-type combination, with its three fixed stat/value pairs in `os_protocol_secondary_effects`. Every matching protocol inherits that profile; secondary values are not copied onto individual protocol records. -The unique primary value uses `base × (1 + (level − 1) / 49)` for levels 1–50, while -talent effects use `base × (1 + upgrades / 5)` for zero to five upgrades. -Server actions validate all relationships and append the full owned graph to -the existing `game_data` audit history. +The unique primary value uses `base × (1 + (level − 1) / 49)` for levels 1–50, +while talent effects use `base × (1 + upgrades / 5)` for zero to five upgrades. +Server actions validate all relationships and append the full owned graph to the +existing `game_data` audit history. ### Weapon Mods and tiered weapon slots -`weapon_mod_slots` records each weapon's available slot vocabulary per tier. -The editor captures one shared set for T1/T2 and the single shared slot omitted -at T0, then expands that input into the normalized per-tier rows. Operators may +`weapon_mod_slots` records each weapon's available slot vocabulary per tier. The +editor captures one shared set for T1/T2 and the single shared slot omitted at +T0, then expands that input into the normalized per-tier rows. Operators may save partial rows when the in-game configuration is unavailable. ISAC derives -`CONFIG REQUIRED` until every available tier contains four unique slots at T0 -or five at T1/T2. Regular Weapon Mods declare a single slot type and one or more -compatible base weapon categories through -`weapon_mod_compatible_categories`; eligibility requires both a category match -and the slot to exist on the equipped weapon at its selected tier. +`CONFIG REQUIRED` until every available tier contains four unique slots at T0 or +five at T1/T2. Regular Weapon Mods declare a single slot type and one or more +compatible base weapon categories through `weapon_mod_compatible_categories`; +eligibility requires both a category match and the slot to exist on the equipped +weapon at its selected tier. Exotic Weapon Mods instead reference one `exotic_weapon_id`. They do not carry regular category compatibility. The builder treats them as permanent equipment @@ -336,62 +422,60 @@ interpolation. All regular Weapon Mods permit Firepower, Toughness, or Engineering as a builder-time primary choice. `weapon_mod_quality_values` stores the shared -level-1 and level-20 primary roll endpoints for each quality; primary values -use linear interpolation from levels 1–20. +level-1 and level-20 primary roll endpoints for each quality; primary values use +linear interpolation from levels 1–20. -`weapon_mod_secondary_options` is the shared pool from which a build selects -two distinct secondary attributes. Every option owns a minimum and maximum at -each quality through `weapon_mod_secondary_quality_values`; secondary -availability is not tied to a numbered slot in canonical data. Mod Effects are -triggerless, hold one or two attribute references, and store fixed per-quality -values in `weapon_mod_effect_quality_values`. +`weapon_mod_secondary_options` is the shared pool from which a build selects two +distinct secondary attributes. Every option owns a minimum and maximum at each +quality through `weapon_mod_secondary_quality_values`; secondary availability is +not tied to a numbered slot in canonical data. Mod Effects are triggerless, hold +one or two attribute references, and store fixed per-quality values in +`weapon_mod_effect_quality_values`. ### Gear Mods and tier slot capacity -Gear Mod definitions are standard-quality records only; there is no exotic -Gear Mod branch. Every definition supplies primary roll ranges for Standard, +Gear Mod definitions are standard-quality records only; there is no exotic Gear +Mod branch. Every definition supplies primary roll ranges for Standard, Specialized, Superior, and High-end quality at Levels 1 and 20. Values between -those endpoints use linear interpolation. A build instance selects one primary attribute -from Firepower, Toughness, Engineering, Health, or Armor. +those endpoints use linear interpolation. A build instance selects one primary +attribute from Firepower, Toughness, Engineering, Health, or Armor. The two secondary slots draw distinct attributes from one shared option pool. Each available secondary attribute stores its own minimum and maximum for all -four qualities. Gear tier determines capacity without per-item configuration: -T0 gear has one Gear Mod slot; T1 and T2 gear have two. +four qualities. Gear tier determines capacity without per-item configuration: T0 +gear has one Gear Mod slot; T1 and T2 gear have two. The `gear_mods`, `gear_mod_quality_values`, `gear_mod_secondary_options`, and -`gear_mod_secondary_quality_values` tables store canonical data. -Successful mutations use `gear_mod.created`, `gear_mod.updated`, -`gear_mod.archived`, and `gear_mod.unarchived` audit actions. Contributor -submissions use the `gearmods` proposal module and remain non-canonical until -approved by Oversight. +`gear_mod_secondary_quality_values` tables store canonical data. Successful +mutations use `gear_mod.created`, `gear_mod.updated`, `gear_mod.archived`, and +`gear_mod.unarchived` audit actions. Contributor submissions use the `gearmods` +proposal module and remain non-canonical until approved by Oversight. # Skill Chip quality scaling Skill Chip sets support Superior and High-end chip instances. Each set owns one -2-piece bonus, distinct primary and secondary stat-option pools, and one -3-piece Talent with a typed conditional rule. Primary and secondary options -reference active Attributes that define Skill Chip minimum and maximum roll -values. A configured chip rolls one primary and two distinct secondary stats -from those pools. +2-piece bonus, distinct primary and secondary stat-option pools, and one 3-piece +Talent with a typed conditional rule. Primary and secondary options reference +active Attributes that define Skill Chip minimum and maximum roll values. A +configured chip rolls one primary and two distinct secondary stats from those +pools. Each active Skill Chip definition belongs to one active specialization ability through `skill_chip_sets.specialization_ability_id`. An ability has capacity for three active definitions. The save and restore actions enforce that capacity, and the schema requires the foreign key for each row. A specialization focus is -builder-ready only when each of its three -assigned abilities has all three active Skill Chip definitions. Skill Chip -mutations refresh readiness for focuses that reference both the previous and -resulting ability. +builder-ready only when each of its three assigned abilities has all three +active Skill Chip definitions. Skill Chip mutations refresh readiness for +focuses that reference both the previous and resulting ability. Both set-bonus progression systems use the number of equipped High-end chips as -the interpolation step. For `h` High-end chips, where `h` is zero through -three, a configured minimum and maximum resolve as +the interpolation step. For `h` High-end chips, where `h` is zero through three, +a configured minimum and maximum resolve as `minimum + (maximum − minimum) × h / 3`. Zero High-end chips therefore produce the minimum, while three produce the maximum. The editor stores endpoints and -previews all four states, preventing duplicated tier data from diverging. -The `game_data` audit stream captures Skill Chip mutations, owned relationships, -and safe icon metadata. +previews all four states, preventing duplicated tier data from diverging. The +`game_data` audit stream captures Skill Chip mutations, owned relationships, and +safe icon metadata. # Mechanic programs and loadout state @@ -413,8 +497,8 @@ Each source supplies a fixed value-variant contract: - Standard Talents require Tier I and Tier II values. Exotic Talents require Tier I and may omit Tier II until contributors document its game values. - Builder calculations use Tier I while an equipped exotic Talent has no Tier - II mechanic variant. + Builder calculations use Tier I while an equipped exotic Talent has no Tier II + mechanic variant. - OS Protocol Talents store a base value. An outcome may use an expression that reads `equipment.protocolTalentUpgrades`, from zero through five, so selected values can scale to twice the base at upgrade five. @@ -457,24 +541,23 @@ separate event from changes to the Agent's current Extra Health state. The optional Action Family catalog provides authoring recipes for common mechanics. Applying a recipe copies its typed scenario inputs, state pools, and clauses into the source-owned program. Those branches become editable -authoritative data; the recipe key remains provenance, not a runtime -dependency. Adding another recipe appends branches. Replacing a program with a -recipe requires confirmation because replacement discards the current branch -graph. +authoritative data; the recipe key remains provenance, not a runtime dependency. +Adding another recipe appends branches. Replacing a program with a recipe +requires confirmation because replacement discards the current branch graph. A Talent may save an empty `captured_only` program while an author records its name, category, image, and source description. The registry marks that Talent -`CONFIG REQUIRED`, and calculations exclude it. Changing readiness to `ready` -or `requires_assumptions` restores strict branch, numbered-outcome, and +`CONFIG REQUIRED`, and calculations exclude it. Changing readiness to `ready` or +`requires_assumptions` restores strict branch, numbered-outcome, and description-placeholder validation. The program graph uses three child tables: - `conditional_effect_state_pools` defines agent- or target-scoped flags, - counts, stacks, durations, scalar values, marks, and resources. Each pool - owns its bounds, expiry policy, refresh policy, and best-case value. -- `conditional_effect_clauses` listens for one event from the registry. A - clause owns a typed predicate, reducer, optional cooldown, optional weight + counts, stacks, durations, scalar values, marks, and resources. Each pool owns + its bounds, expiry policy, refresh policy, and best-case value. +- `conditional_effect_clauses` listens for one event from the registry. A clause + owns a typed predicate, reducer, optional cooldown, optional weight expression, state mutations, and optional recipe provenance. Reducers convert event streams into counts, distinct or consecutive matches, sliding windows, durations, intervals, or derived values. Mutations can set, add, consume, @@ -486,17 +569,17 @@ The program graph uses three child tables: value basis, optional resource or Attribute, combat timing, optional state band, and phase: `on_event`, `after_event`, `on_enter`, `on_exit`, or `while_active`. One typed value or expression exists for every variant - required by the owning source. The common value models are static values, - live Attribute values, percentage-normalized Attribute values, event-field values, reducer output, - source-defined scenario values, and OS Protocol upgrade scaling. Restoration - outcomes may deliver their total immediately or continuously over an explicit - duration. + required by the owning source. The common value models are static values, live + Attribute values, percentage-normalized Attribute values, event-field values, + reducer output, source-defined scenario values, and OS Protocol upgrade + scaling. Restoration outcomes may deliver their total immediately or + continuously over an explicit duration. -The generic runtime in `src/lib/calculations/mechanic.ts` processes combat events -in timestamp order. Predicates decide whether an event enters a clause. Reducers -convert event streams into activations or derived values. The runtime applies -state mutations, enforces pool bounds and expiry, emits outcomes in phase order, -and records telemetry for each activation. +The generic runtime in `src/lib/calculations/mechanic.ts` processes combat +events in timestamp order. Predicates decide whether an event enters a clause. +Reducers convert event streams into activations or derived values. The runtime +applies state mutations, enforces pool bounds and expiry, emits outcomes in +phase order, and records telemetry for each activation. Each write to an expiring pool refreshes its timer. When a pool expires, `on_exit` outcomes evaluate stack thresholds and per-stack expressions against @@ -508,9 +591,9 @@ intermediate ticks. Live Attribute references use a stable Attribute ID and read the authoritative loadout total that the builder captures for the evaluation pass. Percentage Attributes retain their displayed units, so an expression that applies a -coefficient to a percentage divides the Attribute value by 100. Evaluation -fails when the loadout does not supply a referenced Attribute; the runtime does -not substitute zero. +coefficient to a percentage divides the Attribute value by 100. Evaluation fails +when the loadout does not supply a referenced Attribute; the runtime does not +substitute zero. The read-only `combat.currentHealthPercent` reference divides resolved current Health by resolved maximum Health and returns a value from 0 through 100. The @@ -543,8 +626,8 @@ formatter regenerates the listing after each change. Manual mechanic authoring remains the complete authoring path. The AI assistant produces an optional draft for the existing structured editor. It cannot save, -publish, approve, or submit game data, and it cannot alter application code. -An author may ignore, revise, or replace any generated field through the manual +publish, approve, or submit game data, and it cannot alter application code. An +author may ignore, revise, or replace any generated field through the manual controls. The shared mechanic editor exposes the mechanic assistant for standard and @@ -569,12 +652,12 @@ the parser, so CI blocks a prompt that advertises unsupported fields. A mechanic grammar change must update the manifest and example, pass the round-trip test, and increment the prompt contract version before release. -The OpenAI integration uses the Responses API with Structured Outputs, no -tools, and `store: false`. The application stores the bounded clarification -history needed for the current drafting session. Requests use a fixed -server-configured model, a privacy-preserving user safety identifier, input and -output limits, timeouts, duplicate-request protection, and per-agent rate -limits. The API key remains on the server. +The OpenAI integration uses the Responses API with Structured Outputs, no tools, +and `store: false`. The application stores the bounded clarification history +needed for the current drafting session. Requests use a fixed server-configured +model, a privacy-preserving user safety identifier, input and output limits, +timeouts, duplicate-request protection, and per-agent rate limits. The API key +remains on the server. The model returns one structured result: @@ -587,15 +670,15 @@ The model returns one structured result: reference, and test changes a developer would need to add support. For a mechanic capability gap, the client builds a developer prompt from the -exact analyzed wording, clarification transcript, source profile, and -structured gap response. The author can copy that prompt without another model -request. The author can also submit the gap to active full administrators. -The submission endpoint validates bounded fields, confirms that the request ID -belongs to the author's audited `capability_gap` generation, suppresses repeat -submissions, sends mandatory ISAC notifications, and appends a -`mechanic_gap.submitted` event to the `ai_assistance` audit category. The -notification links to the filtered audit evidence. Submission does not mutate -the mechanic draft or parent game-data record. +exact analyzed wording, clarification transcript, source profile, and structured +gap response. The author can copy that prompt without another model request. The +author can also submit the gap to active full administrators. The submission +endpoint validates bounded fields, confirms that the request ID belongs to the +author's audited `capability_gap` generation, suppresses repeat submissions, +sends mandatory ISAC notifications, and appends a `mechanic_gap.submitted` event +to the `ai_assistance` audit category. The notification links to the filtered +audit evidence. Submission does not mutate the mechanic draft or parent +game-data record. The server validates `draft_ready` output against the response schema and the same deterministic mechanic parser used by manual authoring. Unknown events, @@ -616,11 +699,11 @@ allowed by their data role. Successful generation operations append sanitized events under the `ai_assistance` audit category. Events identify the operator, source type, prompt-context version, model, result type, token usage, request ID, and -validation result. Audit snapshots exclude the API key, -environment configuration, hidden model reasoning, authentication data, and -unbounded prompt content. Applying a draft changes client state and creates no -server event. The owning module's game-data audit event remains the record of -any saved mechanic change. +validation result. Audit snapshots exclude the API key, environment +configuration, hidden model reasoning, authentication data, and unbounded prompt +content. Applying a draft changes client state and creates no server event. The +owning module's game-data audit event remains the record of any saved mechanic +change. ### AI-assisted calculation-revision drafting @@ -628,16 +711,16 @@ The calculation assistant converts an operator's plain-language combat-model request into an optional draft revision. It receives the current active or edited revision, the calculation-stage vocabulary, weapon and Skill profile contracts, and the active ISAC Attribute catalog. Its versioned prompt, -structured response schema, validation, and application adapter remain -separate from mechanic drafting. +structured response schema, validation, and application adapter remain separate +from mechanic drafting. -The assistant returns clarification questions, a validated revision draft, or -a calculation capability gap. A capability gap identifies the missing stage, +The assistant returns clarification questions, a validated revision draft, or a +calculation capability gap. A capability gap identifies the missing stage, operator, curve, binding, runtime behavior, or validation rule and lists the application and test changes needed to support it. The server rejects unknown stage keys, locked-position changes, duplicate bindings, invalid Attribute -references, unsupported operators, and malformed curve or cap values through -the deterministic calculation-revision parser. +references, unsupported operators, and malformed curve or cap values through the +deterministic calculation-revision parser. The `ai.calculation.generate` permission authorizes calculation drafting and clarification requests. It does not grant revision mutation or publication. @@ -657,8 +740,8 @@ Source authors define the builder controls required by their mechanic as typed toggle, slider, or numeric scenario inputs. Each input records its label, guidance, bounds when applicable, default value, and best-case value. These controls appear only in advanced scenario configuration. Maximum mode supplies -best-case values. Custom mode uses the values saved with the loadout. A -mechanic with an undefined required assumption remains excluded and reports +best-case values. Custom mode uses the values saved with the loadout. A mechanic +with an undefined required assumption remains excluded and reports `REQUIRES_ASSUMPTIONS`. Loadout schema version 4 stores one focus, selected tree ranks, nine Skill @@ -673,8 +756,8 @@ The builder offers `maximum` and `custom` modes. - Maximum mode supplies each scenario input's best-case value, selects peak conditional-effect state, and runs selected abilities on cooldown against - their configured target caps. The interface hides granular assumptions in - this mode. + their configured target caps. The interface hides granular assumptions in this + mode. - Custom mode uses the agent's encounter inputs, effect states, activation rates, target counts, and other supported assumptions. A missing required input produces `REQUIRES_ASSUMPTIONS` rather than a fabricated value. @@ -689,8 +772,8 @@ estimate. Shredding damage, direct or periodic delivery, hit or tick counts, cast time, target cap, and charge behavior. `skill_attribute_calculation_profiles` binds Attributes to the versioned Skill damage and cadence stages. Cooldown Recovery -changes charge and activation readiness. Duration changes the number of -periodic ticks. +changes charge and activation readiness. Duration changes the number of periodic +ticks. Periodic delivery waits one configured tick interval before its first tick and excludes ticks at or after the encounter boundary. The Skill engine reevaluates @@ -734,11 +817,10 @@ state. Custom mode can set an effect to Peak, Encounter, or Off. The Skill engine uses a dedicated intrinsic ability-damage model. It supports direct and periodic delivery, critical and multi-shot expectation, target caps, -per-ability activation rates, and charge recovery. -Skills do not headshot. Dynamic Talent and tree-effect windows apply to each -Skill hit at its delivery time. Encounter-triggered Skill modifiers use the -primary weapon timeline until the builder supports a combined weapon-rotation -model. +per-ability activation rates, and charge recovery. Skills do not headshot. +Dynamic Talent and tree-effect windows apply to each Skill hit at its delivery +time. Encounter-triggered Skill modifiers use the primary weapon timeline until +the builder supports a combined weapon-rotation model. The engine returns `INSUFFICIENT_DATA` with missing requirement keys when a calculation lacks required inputs or an Attribute binding. Callers must render @@ -751,12 +833,12 @@ Ready cases require the four hit values plus magazine and reload times. The assisted suggestion action compares ready observations with their predictions and creates a draft revision for operator review. -Rule activation requires `calculation.publish` and a confirmation. -ISAC records draft creation, draft edits, publication, calibration changes, and -assisted suggestions under the `calculation_configuration` audit category. -ISAC data export and environment synchronization include calculation rules, -typed mechanic programs, ability attacks, calibration cases, and their -relationships in dependency order. +Rule activation requires `calculation.publish` and a confirmation. ISAC records +draft creation, draft edits, publication, calibration changes, and assisted +suggestions under the `calculation_configuration` audit category. ISAC data +export and environment synchronization include calculation rules, typed mechanic +programs, ability attacks, calibration cases, and their relationships in +dependency order. ## OS Protocol upgrade expressions and health timing @@ -793,9 +875,9 @@ rasterize and normalize each accepted icon to a transparent 256 × 256 WebP. Normalized files live beneath the ignored `data/game-icons/` directory. The validated `/icons/game-data/[type]/[id]` endpoint serves them with immutable -cache headers and disables MIME sniffing. ISAC save actions process files -before mutation, apply upload or removal after record validation, and include -safe icon metadata, rather than image bytes, in audit snapshots. +cache headers and disables MIME sniffing. ISAC save actions process files before +mutation, apply upload or removal after record validation, and include safe icon +metadata, rather than image bytes, in audit snapshots. Icon metadata is optional. Canonical records and contributor proposals may be created without an upload, which lets contributors submit values and artwork in @@ -820,8 +902,8 @@ keeps the library metadata and one copy of each file. The notification router sends Data Change review items to agents who hold `data.approve` and Avatar Oversight review items to agents who hold -`avatars.review`. Each reviewer chooses Immediate, 15-minute, one-hour, or -daily delivery for each authorized queue. Submission creates Immediate alerts; +`avatars.review`. Each reviewer chooses Immediate, 15-minute, one-hour, or daily +delivery for each authorized queue. Submission creates Immediate alerts; `oversight_notification_queue` stores consolidated alerts. Production must run the idempotent dispatcher once per minute from one scheduler @@ -831,12 +913,12 @@ instance: * * * * * cd /path/to/shd.build && npm run notifications:oversight:production ``` -The dispatcher groups due rows by reviewer, queue, cadence, and delivery -window. Daily rows use the next 10:00 AM wall-clock boundary in -`America/New_York`, including daylight-saving transitions. Dispatched rows -retain `delivered_at` evidence, and later queries exclude them. Existing -ISAC notification delivery preferences determine optional browser and email -fan-out after the dispatcher creates the consolidated inbox message. +The dispatcher groups due rows by reviewer, queue, cadence, and delivery window. +Daily rows use the next 10:00 AM wall-clock boundary in `America/New_York`, +including daylight-saving transitions. Dispatched rows retain `delivered_at` +evidence, and later queries exclude them. Existing ISAC notification delivery +preferences determine optional browser and email fan-out after the dispatcher +creates the consolidated inbox message. ## Automated ISAC system messages @@ -847,15 +929,15 @@ managed through ISAC → System Configuration → Automated Messages by agents w enabled state, delay in minutes, message content, primary destination, and structured action callouts. -Password, magic-link, and Discord registration emit `account.created`. -The delivery table has a unique message/user/event/event-ID constraint, so the -same account creation cannot deliver the Welcome Agent message more than once. -The default welcome introduces News, Database, Builds, Guides, Builder, ISAC +Password, magic-link, and Discord registration emit `account.created`. The +delivery table has a unique message/user/event/event-ID constraint, so the same +account creation cannot deliver the Welcome Agent message more than once. The +default welcome introduces News, Database, Builds, Guides, Builder, ISAC Contributor Mode, and the SHD Discord community. The registration request delivers Immediate definitions. -`automated_system_message_deliveries` stores definitions with a delay. Production -must run the delayed dispatcher once per minute: +`automated_system_message_deliveries` stores definitions with a delay. +Production must run the delayed dispatcher once per minute: ```text * * * * * cd /path/to/shd.build && npm run notifications:system:production diff --git a/package-lock.json b/package-lock.json index 01754c4..36fc581 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,3704 +1,4558 @@ { - "name": "shd-build", - "version": "0.0.1", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "shd-build", - "version": "0.0.1", - "dependencies": { - "@fontsource/barlow": "^5.3.0", - "@fontsource/source-serif-4": "^5.3.0", - "@simplewebauthn/browser": "^13.3.0", - "@simplewebauthn/server": "^13.3.2", - "@tursodatabase/database": "^0.7.1", - "argon2": "^0.45.1", - "nodemailer": "^9.0.3", - "openai": "^6.49.0", - "sharp": "^0.35.3" - }, - "devDependencies": { - "@dotenvx/dotenvx": "^2.17.4", - "@sveltejs/adapter-node": "^5.5.7", - "@sveltejs/kit": "^2.63.0", - "@sveltejs/vite-plugin-svelte": "^7.1.2", - "@types/node": "^26.1.1", - "@types/nodemailer": "^8.0.1", - "opentype.js": "^2.0.0", - "svelte": "^5.56.1", - "svelte-check": "^4.6.0", - "typescript": "^6.0.3", - "vite": "^8.0.16", - "vitest": "^4.1.10" - } - }, - "node_modules/@dotenvx/dotenvx": { - "version": "2.17.4", - "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-2.17.4.tgz", - "integrity": "sha512-hzW9xsf9bOUoY1V7xXnl+vZfjqdsdUs0zSOWzm6Zqfcm55nq3YaV+lXmeSzT3ju7xQ6fdSlLjwDQFqrIsMV3wA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@dotenvx/primitives": "^2.1.0", - "@dotenvx/tooling": "^1.0.3", - "yocto-spinner": "^1.2.1" - }, - "bin": { - "dotenvx": "src/cli/dotenvx.js" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/@dotenvx/primitives": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@dotenvx/primitives/-/primitives-2.1.0.tgz", - "integrity": "sha512-GIpMSSgjZk4URRCtAw12vBmobnw3VeSKL+p7/y8ypWvTHFW+StAMZvq4wCjdX+QKuw4alXu0IMGW6QPhKo6ufg==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@dotenvx/tooling": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@dotenvx/tooling/-/tooling-1.0.3.tgz", - "integrity": "sha512-Gd6qQol/ICb4MPRWpiIH56y+pR9AVB7kPBkbhrLSGLtAiF4Qgy+k651/f2pze0Xbm3H5XewbPfIm4y9K+ic30Q==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@epic-web/invariant": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", - "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", - "license": "MIT" - }, - "node_modules/@fontsource/barlow": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@fontsource/barlow/-/barlow-5.3.0.tgz", - "integrity": "sha512-tGR0y6QObRtKv71MOth44222+mdNFM/ZWFWDV1VLzNbG4oK9PNRlyJzmkck4vHAi9ibHBXgt4SlY51amFP55CA==", - "license": "OFL-1.1", - "funding": { - "url": "https://github.com/sponsors/ayuhito" - } - }, - "node_modules/@fontsource/source-serif-4": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@fontsource/source-serif-4/-/source-serif-4-5.3.0.tgz", - "integrity": "sha512-yQz8xmIgMzks8zQbpuca3UYtjxK798XCyQAGdDXmzBgvV7sdoK6d0YvE9F9kiYxORCEMRBgGZDZsSYcXj5KvwA==", - "license": "OFL-1.1", - "funding": { - "url": "https://github.com/sponsors/ayuhito" - } - }, - "node_modules/@hexagon/base64": { - "version": "1.1.28", - "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz", - "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==", - "license": "MIT" - }, - "node_modules/@img/colour": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", - "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", - "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.2" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", - "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.2" - } - }, - "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", - "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "dependencies": { - "@img/sharp-wasm32": "0.35.3" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", - "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", - "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", - "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", - "cpu": [ - "arm" - ], - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", - "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", - "cpu": [ - "arm64" - ], - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", - "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", - "cpu": [ - "ppc64" - ], - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", - "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", - "cpu": [ - "riscv64" - ], - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", - "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", - "cpu": [ - "s390x" - ], - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", - "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", - "cpu": [ - "x64" - ], - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", - "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", - "cpu": [ - "arm64" - ], - "libc": [ - "musl" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", - "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", - "cpu": [ - "x64" - ], - "libc": [ - "musl" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", - "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", - "cpu": [ - "arm" - ], - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.2" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", - "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", - "cpu": [ - "arm64" - ], - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.2" - } - }, - "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", - "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", - "cpu": [ - "ppc64" - ], - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.2" - } - }, - "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", - "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", - "cpu": [ - "riscv64" - ], - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.2" - } - }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", - "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", - "cpu": [ - "s390x" - ], - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.2" - } - }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", - "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", - "cpu": [ - "x64" - ], - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.2" - } - }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", - "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", - "cpu": [ - "arm64" - ], - "libc": [ - "musl" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" - } - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", - "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", - "cpu": [ - "x64" - ], - "libc": [ - "musl" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.2" - } - }, - "node_modules/@img/sharp-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", - "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.11.1" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", - "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", - "cpu": [ - "wasm32" - ], - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@img/sharp-wasm32": "0.35.3" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", - "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", - "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", - "cpu": [ - "ia32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", - "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@levischuck/tiny-cbor": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz", - "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==", - "license": "MIT" - }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", - "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.3" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, - "node_modules/@oxc-project/types": { - "version": "0.139.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", - "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, - "node_modules/@peculiar/asn1-android": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.8.0.tgz", - "integrity": "sha512-skLbS+IOGv1lUgDqtChr8xvtvEr3HMse/JGBaL2r1J1o/n7a8wqOrovMtlRq/UXLhxvmLaONP67hwtshgzwfzA==", - "license": "MIT", - "dependencies": { - "@peculiar/asn1-schema": "^2.8.0", - "asn1js": "^3.0.10", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/asn1-cms": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.8.0.tgz", - "integrity": "sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==", - "license": "MIT", - "dependencies": { - "@peculiar/asn1-schema": "^2.8.0", - "@peculiar/asn1-x509": "^2.8.0", - "@peculiar/asn1-x509-attr": "^2.8.0", - "asn1js": "^3.0.10", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/asn1-csr": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.8.0.tgz", - "integrity": "sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==", - "license": "MIT", - "dependencies": { - "@peculiar/asn1-schema": "^2.8.0", - "@peculiar/asn1-x509": "^2.8.0", - "asn1js": "^3.0.10", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/asn1-ecc": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.8.0.tgz", - "integrity": "sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==", - "license": "MIT", - "dependencies": { - "@peculiar/asn1-schema": "^2.8.0", - "@peculiar/asn1-x509": "^2.8.0", - "asn1js": "^3.0.10", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/asn1-pfx": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.8.0.tgz", - "integrity": "sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==", - "license": "MIT", - "dependencies": { - "@peculiar/asn1-cms": "^2.8.0", - "@peculiar/asn1-pkcs8": "^2.8.0", - "@peculiar/asn1-rsa": "^2.8.0", - "@peculiar/asn1-schema": "^2.8.0", - "asn1js": "^3.0.10", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/asn1-pkcs8": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.8.0.tgz", - "integrity": "sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==", - "license": "MIT", - "dependencies": { - "@peculiar/asn1-schema": "^2.8.0", - "@peculiar/asn1-x509": "^2.8.0", - "asn1js": "^3.0.10", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/asn1-pkcs9": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.8.0.tgz", - "integrity": "sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==", - "license": "MIT", - "dependencies": { - "@peculiar/asn1-cms": "^2.8.0", - "@peculiar/asn1-pfx": "^2.8.0", - "@peculiar/asn1-pkcs8": "^2.8.0", - "@peculiar/asn1-schema": "^2.8.0", - "@peculiar/asn1-x509": "^2.8.0", - "@peculiar/asn1-x509-attr": "^2.8.0", - "asn1js": "^3.0.10", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/asn1-rsa": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.8.0.tgz", - "integrity": "sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==", - "license": "MIT", - "dependencies": { - "@peculiar/asn1-schema": "^2.8.0", - "@peculiar/asn1-x509": "^2.8.0", - "asn1js": "^3.0.10", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/asn1-schema": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz", - "integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==", - "license": "MIT", - "dependencies": { - "@peculiar/utils": "^2.0.2", - "asn1js": "^3.0.10", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/asn1-x509": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.8.0.tgz", - "integrity": "sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==", - "license": "MIT", - "dependencies": { - "@peculiar/asn1-schema": "^2.8.0", - "@peculiar/utils": "^2.0.2", - "asn1js": "^3.0.10", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/asn1-x509-attr": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.8.0.tgz", - "integrity": "sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==", - "license": "MIT", - "dependencies": { - "@peculiar/asn1-schema": "^2.8.0", - "@peculiar/asn1-x509": "^2.8.0", - "asn1js": "^3.0.10", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/utils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", - "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", - "license": "MIT", - "dependencies": { - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/x509": { - "version": "1.14.3", - "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", - "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", - "license": "MIT", - "dependencies": { - "@peculiar/asn1-cms": "^2.6.0", - "@peculiar/asn1-csr": "^2.6.0", - "@peculiar/asn1-ecc": "^2.6.0", - "@peculiar/asn1-pkcs9": "^2.6.0", - "@peculiar/asn1-rsa": "^2.6.0", - "@peculiar/asn1-schema": "^2.6.0", - "@peculiar/asn1-x509": "^2.6.0", - "pvtsutils": "^1.3.6", - "reflect-metadata": "^0.2.2", - "tslib": "^2.8.1", - "tsyringe": "^4.10.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@phc/format": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", - "integrity": "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/@polka/url": { - "version": "1.0.0-next.29", - "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", - "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", - "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", - "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", - "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", - "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", - "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", - "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", - "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", - "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", - "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", - "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", - "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", - "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", - "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.11.1", - "@emnapi/runtime": "1.11.1", - "@napi-rs/wasm-runtime": "^1.1.6" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", - "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", - "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/plugin-commonjs": { - "version": "29.0.3", - "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-29.0.3.tgz", - "integrity": "sha512-ZaOxZceP7SOUW7Lqw5IRVweSQYWaeIPnXIGLiB690EBA3FGJTO40EEr2L5yZplJWsgTCogILRSpcAe7+U0Otdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^5.0.1", - "commondir": "^1.0.1", - "estree-walker": "^2.0.2", - "fdir": "^6.2.0", - "is-reference": "1.2.1", - "magic-string": "^0.30.3", - "picomatch": "^4.0.2" - }, - "engines": { - "node": ">=16.0.0 || 14 >= 14.17" - }, - "peerDependencies": { - "rollup": "^2.68.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/plugin-commonjs/node_modules/is-reference": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", - "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "*" - } - }, - "node_modules/@rollup/plugin-json": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", - "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^5.1.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/plugin-node-resolve": { - "version": "16.0.3", - "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", - "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^5.0.1", - "@types/resolve": "1.20.2", - "deepmerge": "^4.2.2", - "is-module": "^1.0.0", - "resolve": "^1.22.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^2.78.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/plugin-replace": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-6.0.3.tgz", - "integrity": "sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^5.0.1", - "magic-string": "^0.30.3" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/pluginutils": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", - "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "estree-walker": "^2.0.2", - "picomatch": "^4.0.2" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", - "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", - "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", - "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", - "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", - "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", - "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", - "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", - "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", - "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", - "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", - "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", - "cpu": [ - "loong64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", - "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", - "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", - "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", - "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", - "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", - "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", - "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", - "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", - "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", - "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", - "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", - "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", - "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", - "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@simplewebauthn/browser": { - "version": "13.3.0", - "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz", - "integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==", - "license": "MIT" - }, - "node_modules/@simplewebauthn/server": { - "version": "13.3.2", - "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.2.tgz", - "integrity": "sha512-KEDhfcGP1PAKRVSDjA3npTQFqS2b/srm+ipoNBNHdkzrHAlaRQUTE+a5f4ywsx6thxAw1NU2rYcLEY1949RGbQ==", - "license": "MIT", - "dependencies": { - "@hexagon/base64": "^1.1.27", - "@levischuck/tiny-cbor": "^0.2.2", - "@peculiar/asn1-android": "^2.6.0", - "@peculiar/asn1-ecc": "^2.6.1", - "@peculiar/asn1-rsa": "^2.6.1", - "@peculiar/asn1-schema": "^2.6.0", - "@peculiar/asn1-x509": "^2.6.1", - "@peculiar/x509": "^1.14.3" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sveltejs/acorn-typescript": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz", - "integrity": "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^8.9.0" - } - }, - "node_modules/@sveltejs/adapter-node": { - "version": "5.5.7", - "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.5.7.tgz", - "integrity": "sha512-uOfc9eVlI3A37RRSaKcgrheBYPrfJwC9VMqDp8x/O6tlKdcLLvHThSWD0KNIbjQ/d+7bwLGx3vx6aowAcRfd2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rollup/plugin-commonjs": "^29.0.0", - "@rollup/plugin-json": "^6.1.0", - "@rollup/plugin-node-resolve": "^16.0.0", - "@rollup/plugin-replace": "^6.0.3", - "rollup": "^4.59.0" - }, - "peerDependencies": { - "@sveltejs/kit": "^2.4.0" - } - }, - "node_modules/@sveltejs/kit": { - "version": "2.70.1", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.1.tgz", - "integrity": "sha512-nY9SPHGOZro3doud9vZXDBwl9tCZIouuJztjgSHs6PAIrv9M/z5O7eOhPV5xU7CgVHA976Jwu3BA1hIFvXztkA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.0.0", - "@sveltejs/acorn-typescript": "^1.0.9", - "@types/cookie": "^0.6.0", - "acorn": "^8.16.0", - "cookie": "^0.6.0", - "devalue": "^5.8.1", - "esm-env": "^1.2.2", - "kleur": "^4.1.5", - "magic-string": "^0.30.5", - "mrmime": "^2.0.0", - "set-cookie-parser": "^3.0.0", - "sirv": "^3.0.0" - }, - "bin": { - "svelte-kit": "svelte-kit.js" - }, - "engines": { - "node": ">=18.13" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.0.0", - "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", - "svelte": "^4.0.0 || ^5.0.0-next.0", - "typescript": "^5.3.3 || ^6.0.0", - "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "@opentelemetry/api": { - "optional": true - }, - "typescript": { - "optional": true - } - } - }, - "node_modules/@sveltejs/load-config": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.0.tgz", - "integrity": "sha512-1LgZ/qUqSoq+QorD83lk2hka79Px0wXNW2q5V1nZlxGhQgw1jrsIbVz5YiCeucVLo4XvFLjXukUaQjIiqowkcg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 18.0.0" - } - }, - "node_modules/@sveltejs/vite-plugin-svelte": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.2.0.tgz", - "integrity": "sha512-1SpkuMSRLfugrVX+IrKfE1RUegzo8AQzKQ6qQPfVzbcWi5IhuTPaKb5ZrLpucleFznkc4/RTeSPoRnGWFxX+EQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "deepmerge": "^4.3.1", - "magic-string": "^0.30.21", - "obug": "^2.1.0", - "vitefu": "^1.1.2" - }, - "engines": { - "node": "^20.19 || ^22.12 || >=24" - }, - "peerDependencies": { - "svelte": "^5.46.4", - "vite": "^8.0.0-beta.7 || ^8.0.0" - } - }, - "node_modules/@tursodatabase/database": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/@tursodatabase/database/-/database-0.7.1.tgz", - "integrity": "sha512-RPEkpAoxKk0aY8spsUpT0MID7AUq7CHnqHOkA0zTG546XPkP/mkkQr1ZwpUer69z4BItSaDq+OQ3XrxVOFfsHg==", - "license": "MIT", - "dependencies": { - "@tursodatabase/database-common": "^0.7.1" - }, - "optionalDependencies": { - "@tursodatabase/database-darwin-arm64": "0.7.1", - "@tursodatabase/database-linux-arm64-gnu": "0.7.1", - "@tursodatabase/database-linux-x64-gnu": "0.7.1", - "@tursodatabase/database-win32-x64-msvc": "0.7.1" - } - }, - "node_modules/@tursodatabase/database-common": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/@tursodatabase/database-common/-/database-common-0.7.1.tgz", - "integrity": "sha512-tyEKiRJDZSqLTbJQYhvY9Q2pGbBMwhKb8YS5T9TNOaDy1HvZMDbiypYqvtBw04f02QladUIsmpVBlsmFDb+RFA==", - "license": "MIT" - }, - "node_modules/@tursodatabase/database-darwin-arm64": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/@tursodatabase/database-darwin-arm64/-/database-darwin-arm64-0.7.1.tgz", - "integrity": "sha512-lTki3P+yH5GawkBL2zmc0hU7nJQ92WCiKlb3XDfRpkX6UukwmnuAZAYM0oWK5rbOzBd0plZUGdItZGjwM+lPPw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@tursodatabase/database-linux-arm64-gnu": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/@tursodatabase/database-linux-arm64-gnu/-/database-linux-arm64-gnu-0.7.1.tgz", - "integrity": "sha512-28dQmMUm1Nzvc2B6lRz6sbIRt5bJBVrnRdy2LpEUnYbU1aTvgLsUgfl4NQONbaaKTGzeKdxe8wONjfwkPbgsaA==", - "cpu": [ - "arm64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@tursodatabase/database-linux-x64-gnu": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/@tursodatabase/database-linux-x64-gnu/-/database-linux-x64-gnu-0.7.1.tgz", - "integrity": "sha512-8gENsVswYpwNeuRv/MQJ+aqqT0cwRaDxoaPCeST4IDfzLihl8C5qOrzqJ315pEOCquMKQNVsH8EGPmdjJd7l4g==", - "cpu": [ - "x64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@tursodatabase/database-win32-x64-msvc": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/@tursodatabase/database-win32-x64-msvc/-/database-win32-x64-msvc-0.7.1.tgz", - "integrity": "sha512-+O3k4VPTlgOvzcRfogep8Tsf6MjwZ9BAzC7QD2QAIYZUvraqcXvv+dZTgZnkRJ6XI5Fu/3ePr8a6SkqbXjhoMA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", - "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, - "node_modules/@types/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "26.1.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", - "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~8.3.0" - } - }, - "node_modules/@types/nodemailer": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz", - "integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/resolve": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", - "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@vitest/expect": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", - "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", - "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "4.1.10", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/mocker/node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", - "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", - "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.10", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", - "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.10", - "@vitest/utils": "4.1.10", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", - "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", - "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.10", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/acorn": { - "version": "8.17.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", - "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/argon2": { - "version": "0.45.1", - "resolved": "https://registry.npmjs.org/argon2/-/argon2-0.45.1.tgz", - "integrity": "sha512-skm+/WCjkGqCQxF7FG1LuZXM5yvbFjgbfiCGsud2oLgaDhh6b6dbH0b1EkghbM+xx4Bj8Ape+KKgixoIlWZicQ==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "@phc/format": "^1.0.0", - "cross-env": "^10.1.0", - "node-addon-api": "^8.9.0", - "node-gyp-build": "^4.8.4" - }, - "engines": { - "node": ">=16.17.0" - } - }, - "node_modules/aria-query": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", - "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/asn1js": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", - "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", - "license": "BSD-3-Clause", - "dependencies": { - "pvtsutils": "^1.3.6", - "pvutils": "^1.1.5", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/axobject-query": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", - "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/commondir": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", - "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", - "dev": true, - "license": "MIT" - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cross-env": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", - "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", - "license": "MIT", - "dependencies": { - "@epic-web/invariant": "^1.0.0", - "cross-spawn": "^7.0.6" - }, - "bin": { - "cross-env": "dist/bin/cross-env.js", - "cross-env-shell": "dist/bin/cross-env-shell.js" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/devalue": { - "version": "5.8.2", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.2.tgz", - "integrity": "sha512-DObPPAfdtFbXjxLqK8s2Xk9ZuWz5+ZoFEhC7J76es4GU/rEiXwHTmbImoCdyoCOcBH1UF3+Cz6Z2sYD4hyl5TA==", - "dev": true, - "license": "MIT" - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-module-lexer": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", - "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", - "dev": true, - "license": "MIT" - }, - "node_modules/esm-env": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", - "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/esrap": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.0.tgz", - "integrity": "sha512-GQ/7RN8uOtEfNpzZzBMTzW9JBcX42oaSVtPzdF+6cEL8pqIL094iUpr9jzYGn4O4P/1S60dJ6izyT8F4LYARng==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15" - }, - "peerDependencies": { - "@typescript-eslint/types": "^8.2.0" - }, - "peerDependenciesMeta": { - "@typescript-eslint/types": { - "optional": true - } - } - }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true, - "license": "MIT" - }, - "node_modules/expect-type": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", - "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/is-core-module": { - "version": "2.16.2", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", - "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-module": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", - "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-reference": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", - "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.6" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/kleur": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", - "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/lightningcss": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", - "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.33.0", - "lightningcss-darwin-arm64": "1.33.0", - "lightningcss-darwin-x64": "1.33.0", - "lightningcss-freebsd-x64": "1.33.0", - "lightningcss-linux-arm-gnueabihf": "1.33.0", - "lightningcss-linux-arm64-gnu": "1.33.0", - "lightningcss-linux-arm64-musl": "1.33.0", - "lightningcss-linux-x64-gnu": "1.33.0", - "lightningcss-linux-x64-musl": "1.33.0", - "lightningcss-win32-arm64-msvc": "1.33.0", - "lightningcss-win32-x64-msvc": "1.33.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", - "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", - "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", - "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", - "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", - "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", - "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", - "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", - "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", - "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", - "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", - "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/locate-character": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", - "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", - "dev": true, - "license": "MIT" - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/mri": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", - "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/mrmime": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", - "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/node-addon-api": { - "version": "8.9.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz", - "integrity": "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==", - "license": "MIT", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, - "node_modules/node-gyp-build": { - "version": "4.8.4", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", - "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", - "license": "MIT", - "bin": { - "node-gyp-build": "bin.js", - "node-gyp-build-optional": "optional.js", - "node-gyp-build-test": "build-test.js" - } - }, - "node_modules/nodemailer": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz", - "integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==", - "license": "MIT-0", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/obug": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", - "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", - "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT", - "engines": { - "node": ">=12.20.0" - } - }, - "node_modules/openai": { - "version": "6.49.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-6.49.0.tgz", - "integrity": "sha512-aYCc0C6L864eR6WSYIwQGyXriw/nIyZx0ObvhzOEVuk0zoBDpynjSbrionWI7q65B5H8jJX0DXR9snEzM6bfPg==", - "license": "Apache-2.0", - "peerDependencies": { - "@aws-sdk/credential-provider-node": ">=3.972.0 <4", - "@smithy/hash-node": ">=4.3.0 <5", - "@smithy/signature-v4": ">=5.4.0 <6", - "ws": "^8.18.0", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "@aws-sdk/credential-provider-node": { - "optional": true - }, - "@smithy/hash-node": { - "optional": true - }, - "@smithy/signature-v4": { - "optional": true - }, - "ws": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, - "node_modules/opentype.js": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/opentype.js/-/opentype.js-2.0.0.tgz", - "integrity": "sha512-kCyjv6xdDY1W/jLWZ/L3QhhTlKUqDZMQ5+Jdlw12b3dXkKNpYBqqlMMj0YDQPShWFTMwgZI1hG14kN3XUDSg/A==", - "dev": true, - "license": "MIT", - "bin": { - "ot": "bin/ot" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.23", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", - "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.16", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/pvtsutils": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", - "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", - "license": "MIT", - "dependencies": { - "tslib": "^2.8.1" - } - }, - "node_modules/pvutils": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", - "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", - "license": "MIT", - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/reflect-metadata": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", - "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "license": "Apache-2.0" - }, - "node_modules/resolve": { - "version": "1.22.12", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", - "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/rolldown": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", - "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/types": "=0.139.0", - "@rolldown/pluginutils": "^1.0.0" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.1.5", - "@rolldown/binding-darwin-arm64": "1.1.5", - "@rolldown/binding-darwin-x64": "1.1.5", - "@rolldown/binding-freebsd-x64": "1.1.5", - "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", - "@rolldown/binding-linux-arm64-gnu": "1.1.5", - "@rolldown/binding-linux-arm64-musl": "1.1.5", - "@rolldown/binding-linux-ppc64-gnu": "1.1.5", - "@rolldown/binding-linux-s390x-gnu": "1.1.5", - "@rolldown/binding-linux-x64-gnu": "1.1.5", - "@rolldown/binding-linux-x64-musl": "1.1.5", - "@rolldown/binding-openharmony-arm64": "1.1.5", - "@rolldown/binding-wasm32-wasi": "1.1.5", - "@rolldown/binding-win32-arm64-msvc": "1.1.5", - "@rolldown/binding-win32-x64-msvc": "1.1.5" - } - }, - "node_modules/rollup": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", - "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.9" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.62.2", - "@rollup/rollup-android-arm64": "4.62.2", - "@rollup/rollup-darwin-arm64": "4.62.2", - "@rollup/rollup-darwin-x64": "4.62.2", - "@rollup/rollup-freebsd-arm64": "4.62.2", - "@rollup/rollup-freebsd-x64": "4.62.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", - "@rollup/rollup-linux-arm-musleabihf": "4.62.2", - "@rollup/rollup-linux-arm64-gnu": "4.62.2", - "@rollup/rollup-linux-arm64-musl": "4.62.2", - "@rollup/rollup-linux-loong64-gnu": "4.62.2", - "@rollup/rollup-linux-loong64-musl": "4.62.2", - "@rollup/rollup-linux-ppc64-gnu": "4.62.2", - "@rollup/rollup-linux-ppc64-musl": "4.62.2", - "@rollup/rollup-linux-riscv64-gnu": "4.62.2", - "@rollup/rollup-linux-riscv64-musl": "4.62.2", - "@rollup/rollup-linux-s390x-gnu": "4.62.2", - "@rollup/rollup-linux-x64-gnu": "4.62.2", - "@rollup/rollup-linux-x64-musl": "4.62.2", - "@rollup/rollup-openbsd-x64": "4.62.2", - "@rollup/rollup-openharmony-arm64": "4.62.2", - "@rollup/rollup-win32-arm64-msvc": "4.62.2", - "@rollup/rollup-win32-ia32-msvc": "4.62.2", - "@rollup/rollup-win32-x64-gnu": "4.62.2", - "@rollup/rollup-win32-x64-msvc": "4.62.2", - "fsevents": "~2.3.2" - } - }, - "node_modules/sade": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", - "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", - "dev": true, - "license": "MIT", - "dependencies": { - "mri": "^1.1.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/set-cookie-parser": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", - "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==", - "dev": true, - "license": "MIT" - }, - "node_modules/sharp": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", - "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", - "license": "Apache-2.0", - "dependencies": { - "@img/colour": "^1.1.0", - "detect-libc": "^2.1.2", - "semver": "^7.8.5" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.3", - "@img/sharp-darwin-x64": "0.35.3", - "@img/sharp-freebsd-wasm32": "0.35.3", - "@img/sharp-libvips-darwin-arm64": "1.3.2", - "@img/sharp-libvips-darwin-x64": "1.3.2", - "@img/sharp-libvips-linux-arm": "1.3.2", - "@img/sharp-libvips-linux-arm64": "1.3.2", - "@img/sharp-libvips-linux-ppc64": "1.3.2", - "@img/sharp-libvips-linux-riscv64": "1.3.2", - "@img/sharp-libvips-linux-s390x": "1.3.2", - "@img/sharp-libvips-linux-x64": "1.3.2", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", - "@img/sharp-libvips-linuxmusl-x64": "1.3.2", - "@img/sharp-linux-arm": "0.35.3", - "@img/sharp-linux-arm64": "0.35.3", - "@img/sharp-linux-ppc64": "0.35.3", - "@img/sharp-linux-riscv64": "0.35.3", - "@img/sharp-linux-s390x": "0.35.3", - "@img/sharp-linux-x64": "0.35.3", - "@img/sharp-linuxmusl-arm64": "0.35.3", - "@img/sharp-linuxmusl-x64": "0.35.3", - "@img/sharp-webcontainers-wasm32": "0.35.3", - "@img/sharp-win32-arm64": "0.35.3", - "@img/sharp-win32-ia32": "0.35.3", - "@img/sharp-win32-x64": "0.35.3" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/sirv": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", - "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@polka/url": "^1.0.0-next.24", - "mrmime": "^2.0.0", - "totalist": "^3.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/std-env": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", - "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", - "dev": true, - "license": "MIT" - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/svelte": { - "version": "5.56.8", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.8.tgz", - "integrity": "sha512-PY8LOw7xP6c8IOiVqdo0sbbZVYhXRSfklOQLAUyGBKqjTX0wx/z4l/9J+PmBpmlLnxzEb1NqltxQ5/wZme/Cmg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.4", - "@jridgewell/sourcemap-codec": "^1.5.0", - "@sveltejs/acorn-typescript": "^1.0.10", - "@types/estree": "^1.0.5", - "@types/trusted-types": "^2.0.7", - "acorn": "^8.12.1", - "aria-query": "5.3.1", - "axobject-query": "^4.1.0", - "clsx": "^2.1.1", - "devalue": "^5.8.1", - "esm-env": "^1.2.1", - "esrap": "^2.2.12", - "is-reference": "^3.0.3", - "locate-character": "^3.0.0", - "magic-string": "^0.30.11", - "zimmerframe": "^1.1.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/svelte-check": { - "version": "4.7.3", - "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.3.tgz", - "integrity": "sha512-DHdTCGX62R0fCxBEaT+USdASAnoaRBaaNczkRJl0K7o3WyoCeVUbVxo6fKqpOll/B+WMWCsiFK0eFrJSNBKZIg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.25", - "@sveltejs/load-config": "^0.2.0", - "chokidar": "^4.0.1", - "fdir": "^6.2.0", - "picocolors": "^1.0.0", - "sade": "^1.7.4" - }, - "bin": { - "svelte-check": "bin/svelte-check" - }, - "engines": { - "node": ">= 18.0.0" - }, - "peerDependencies": { - "svelte": "^4.0.0 || ^5.0.0-next.0", - "typescript": ">=5.0.0" - } - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", - "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/totalist": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", - "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/tsyringe": { - "version": "4.10.0", - "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", - "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", - "license": "MIT", - "dependencies": { - "tslib": "^1.9.3" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/tsyringe/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "license": "0BSD" - }, - "node_modules/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", - "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/vite": { - "version": "8.1.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", - "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", - "dev": true, - "license": "MIT", - "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.5", - "postcss": "^8.5.17", - "rolldown": "~1.1.5", - "tinyglobby": "^0.2.17" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.3.0", - "esbuild": "^0.27.0 || ^0.28.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vitefu": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", - "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", - "dev": true, - "license": "MIT", - "workspaces": [ - "tests/deps/*", - "tests/projects/*", - "tests/projects/workspace/packages/*" - ], - "peerDependencies": { - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "vite": { - "optional": true - } - } - }, - "node_modules/vitest": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", - "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "4.1.10", - "@vitest/mocker": "4.1.10", - "@vitest/pretty-format": "4.1.10", - "@vitest/runner": "4.1.10", - "@vitest/snapshot": "4.1.10", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.10", - "@vitest/browser-preview": "4.1.10", - "@vitest/browser-webdriverio": "4.1.10", - "@vitest/coverage-istanbul": "4.1.10", - "@vitest/coverage-v8": "4.1.10", - "@vitest/ui": "4.1.10", - "happy-dom": "*", - "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser-playwright": { - "optional": true - }, - "@vitest/browser-preview": { - "optional": true - }, - "@vitest/browser-webdriverio": { - "optional": true - }, - "@vitest/coverage-istanbul": { - "optional": true - }, - "@vitest/coverage-v8": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - }, - "vite": { - "optional": false - } - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yocto-spinner": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/yocto-spinner/-/yocto-spinner-1.2.2.tgz", - "integrity": "sha512-DODGl1wJjA/s5pnJFKau9lIYHT81lnhob1i3e1TjxZRxEhWRKl74nTbWE6H5KlkViQQTo/Z29YFdxzTZAMY3ng==", - "dev": true, - "license": "MIT", - "dependencies": { - "yoctocolors": "^2.1.1" - }, - "engines": { - "node": ">=18.19" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/yoctocolors": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", - "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/zimmerframe": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", - "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", - "dev": true, - "license": "MIT" - } - } + "name": "shd-build", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "shd-build", + "version": "0.0.1", + "dependencies": { + "@fontsource/barlow": "^5.3.0", + "@fontsource/source-serif-4": "^5.3.0", + "@simplewebauthn/browser": "^13.3.0", + "@simplewebauthn/server": "^13.3.2", + "@tiptap/core": "3.29.2", + "@tiptap/extension-link": "3.29.2", + "@tiptap/extensions": "3.29.2", + "@tiptap/pm": "3.29.2", + "@tiptap/starter-kit": "3.29.2", + "@tursodatabase/database": "^0.7.1", + "argon2": "^0.45.1", + "nodemailer": "^9.0.3", + "openai": "^6.49.0", + "sharp": "^0.35.3" + }, + "devDependencies": { + "@dotenvx/dotenvx": "^2.17.4", + "@sveltejs/adapter-node": "^5.5.7", + "@sveltejs/kit": "^2.63.0", + "@sveltejs/vite-plugin-svelte": "^7.1.2", + "@types/node": "^26.1.1", + "@types/nodemailer": "^8.0.1", + "husky": "^9.1.7", + "lint-staged": "^16.4.0", + "opentype.js": "^2.0.0", + "prettier": "3.9.6", + "svelte": "^5.56.1", + "svelte-check": "^4.6.0", + "typescript": "^6.0.3", + "vite": "^8.0.16", + "vitest": "^4.1.10" + } + }, + "node_modules/@dotenvx/dotenvx": { + "version": "2.17.4", + "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-2.17.4.tgz", + "integrity": "sha512-hzW9xsf9bOUoY1V7xXnl+vZfjqdsdUs0zSOWzm6Zqfcm55nq3YaV+lXmeSzT3ju7xQ6fdSlLjwDQFqrIsMV3wA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@dotenvx/primitives": "^2.1.0", + "@dotenvx/tooling": "^1.0.3", + "yocto-spinner": "^1.2.1" + }, + "bin": { + "dotenvx": "src/cli/dotenvx.js" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/@dotenvx/primitives": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@dotenvx/primitives/-/primitives-2.1.0.tgz", + "integrity": "sha512-GIpMSSgjZk4URRCtAw12vBmobnw3VeSKL+p7/y8ypWvTHFW+StAMZvq4wCjdX+QKuw4alXu0IMGW6QPhKo6ufg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@dotenvx/tooling": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@dotenvx/tooling/-/tooling-1.0.3.tgz", + "integrity": "sha512-Gd6qQol/ICb4MPRWpiIH56y+pR9AVB7kPBkbhrLSGLtAiF4Qgy+k651/f2pze0Xbm3H5XewbPfIm4y9K+ic30Q==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@epic-web/invariant": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", + "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", + "license": "MIT" + }, + "node_modules/@fontsource/barlow": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/barlow/-/barlow-5.3.0.tgz", + "integrity": "sha512-tGR0y6QObRtKv71MOth44222+mdNFM/ZWFWDV1VLzNbG4oK9PNRlyJzmkck4vHAi9ibHBXgt4SlY51amFP55CA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/source-serif-4": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/source-serif-4/-/source-serif-4-5.3.0.tgz", + "integrity": "sha512-yQz8xmIgMzks8zQbpuca3UYtjxK798XCyQAGdDXmzBgvV7sdoK6d0YvE9F9kiYxORCEMRBgGZDZsSYcXj5KvwA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@hexagon/base64": { + "version": "1.1.28", + "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz", + "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==", + "license": "MIT" + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@levischuck/tiny-cbor": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz", + "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==", + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@peculiar/asn1-android": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.8.0.tgz", + "integrity": "sha512-skLbS+IOGv1lUgDqtChr8xvtvEr3HMse/JGBaL2r1J1o/n7a8wqOrovMtlRq/UXLhxvmLaONP67hwtshgzwfzA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.8.0.tgz", + "integrity": "sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "@peculiar/asn1-x509-attr": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.8.0.tgz", + "integrity": "sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.8.0.tgz", + "integrity": "sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.8.0.tgz", + "integrity": "sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.8.0", + "@peculiar/asn1-pkcs8": "^2.8.0", + "@peculiar/asn1-rsa": "^2.8.0", + "@peculiar/asn1-schema": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.8.0.tgz", + "integrity": "sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.8.0.tgz", + "integrity": "sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.8.0", + "@peculiar/asn1-pfx": "^2.8.0", + "@peculiar/asn1-pkcs8": "^2.8.0", + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "@peculiar/asn1-x509-attr": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.8.0.tgz", + "integrity": "sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz", + "integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==", + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.8.0.tgz", + "integrity": "sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.8.0.tgz", + "integrity": "sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/x509": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", + "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-csr": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.0", + "@peculiar/asn1-pkcs9": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "pvtsutils": "^1.3.6", + "reflect-metadata": "^0.2.2", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@phc/format": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", + "integrity": "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/plugin-commonjs": { + "version": "29.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-29.0.3.tgz", + "integrity": "sha512-ZaOxZceP7SOUW7Lqw5IRVweSQYWaeIPnXIGLiB690EBA3FGJTO40EEr2L5yZplJWsgTCogILRSpcAe7+U0Otdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "commondir": "^1.0.1", + "estree-walker": "^2.0.2", + "fdir": "^6.2.0", + "is-reference": "1.2.1", + "magic-string": "^0.30.3", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=16.0.0 || 14 >= 14.17" + }, + "peerDependencies": { + "rollup": "^2.68.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-commonjs/node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@rollup/plugin-json": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", + "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", + "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-replace": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-6.0.3.tgz", + "integrity": "sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "magic-string": "^0.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", + "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@simplewebauthn/browser": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz", + "integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==", + "license": "MIT" + }, + "node_modules/@simplewebauthn/server": { + "version": "13.3.2", + "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.2.tgz", + "integrity": "sha512-KEDhfcGP1PAKRVSDjA3npTQFqS2b/srm+ipoNBNHdkzrHAlaRQUTE+a5f4ywsx6thxAw1NU2rYcLEY1949RGbQ==", + "license": "MIT", + "dependencies": { + "@hexagon/base64": "^1.1.27", + "@levischuck/tiny-cbor": "^0.2.2", + "@peculiar/asn1-android": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.1", + "@peculiar/asn1-rsa": "^2.6.1", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "@peculiar/x509": "^1.14.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz", + "integrity": "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-node": { + "version": "5.5.7", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.5.7.tgz", + "integrity": "sha512-uOfc9eVlI3A37RRSaKcgrheBYPrfJwC9VMqDp8x/O6tlKdcLLvHThSWD0KNIbjQ/d+7bwLGx3vx6aowAcRfd2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/plugin-commonjs": "^29.0.0", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.0", + "@rollup/plugin-replace": "^6.0.3", + "rollup": "^4.59.0" + }, + "peerDependencies": { + "@sveltejs/kit": "^2.4.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.70.1", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.1.tgz", + "integrity": "sha512-nY9SPHGOZro3doud9vZXDBwl9tCZIouuJztjgSHs6PAIrv9M/z5O7eOhPV5xU7CgVHA976Jwu3BA1hIFvXztkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.9", + "@types/cookie": "^0.6.0", + "acorn": "^8.16.0", + "cookie": "^0.6.0", + "devalue": "^5.8.1", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3 || ^6.0.0", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/load-config": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.0.tgz", + "integrity": "sha512-1LgZ/qUqSoq+QorD83lk2hka79Px0wXNW2q5V1nZlxGhQgw1jrsIbVz5YiCeucVLo4XvFLjXukUaQjIiqowkcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.2.0.tgz", + "integrity": "sha512-1SpkuMSRLfugrVX+IrKfE1RUegzo8AQzKQ6qQPfVzbcWi5IhuTPaKb5ZrLpucleFznkc4/RTeSPoRnGWFxX+EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.2" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" + } + }, + "node_modules/@tiptap/core": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.29.2.tgz", + "integrity": "sha512-oKUkiPUB7noilVYxI9lNzUD4rX17sHub+PYjMfHMWHG9A3nvIy+FdePIVIIhThKWF7ijhr3eIqHY51Bn+GAFtw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/pm": "3.29.2" + } + }, + "node_modules/@tiptap/extension-blockquote": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.29.2.tgz", + "integrity": "sha512-ca4OzKDh0yaxg2+Z56bC2QnWsNsFp2YMRfVig1PDXyMVFMNJpLcnhxgq/9btn+xYAlYrj8RymOeCTYREOR6Zjg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.29.2", + "@tiptap/pm": "3.29.2" + } + }, + "node_modules/@tiptap/extension-bold": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.29.2.tgz", + "integrity": "sha512-elYbGxJsYnBb4leqrcjdIJuiG380BcOgN+UUzvOv+qEjfGVzHodFOMBl3qnmD6urYHNu5/qQK2S0qSSRXKCLNQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.29.2" + } + }, + "node_modules/@tiptap/extension-bullet-list": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.29.2.tgz", + "integrity": "sha512-3bWcCUPbCHv0XttlMdnAtXLNYWx2pblByMgxmGsaP9FU0QnslGXty6A6gHCqI33ygRg1vrA6U5Wtpwbi5aKu5g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.29.2" + } + }, + "node_modules/@tiptap/extension-code": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.29.2.tgz", + "integrity": "sha512-c6W5UGuB7WNLpYocsgRzpO2OOTI4QjaI9jjHRMuty9z+s9DtaYM/HrRLNwVh6MopkHb+i/89Wkv8gCS34fftig==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.29.2" + } + }, + "node_modules/@tiptap/extension-code-block": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.29.2.tgz", + "integrity": "sha512-w153ct8g6dLiPTdXQ6SOIMxX4SEo5Q50AmjdEEEcJ7ZcYUcde/ScSskLHfOYmyt5ZFAiyEwr121+pux+p3/oAQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.29.2", + "@tiptap/pm": "3.29.2" + } + }, + "node_modules/@tiptap/extension-document": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.29.2.tgz", + "integrity": "sha512-YUamvefLnsqu6124GavVTI7nqcFlQJ12ROB0oSwG69eSBZYNjg1tIs05LFrBxkwf4Xgqd6YzfJ9+FeG428RvzQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.29.2" + } + }, + "node_modules/@tiptap/extension-dropcursor": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.29.2.tgz", + "integrity": "sha512-KKno7cU9r1HdR48CRrsDu69/1UjZdoslq/UcE+Kx+tdhAv/aljXMkRSNzGMrBNOBDmHRgS1+58zm21WQWdQzwA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.29.2" + } + }, + "node_modules/@tiptap/extension-gapcursor": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.29.2.tgz", + "integrity": "sha512-8Q39UR4/Tit759IeW9xZIe3NMwN11GsuA3FLheDyyGn7RrW02HD3HhUDlazE54Ki4HoosjFmChPlN4Ik2ubdRQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.29.2" + } + }, + "node_modules/@tiptap/extension-hard-break": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.29.2.tgz", + "integrity": "sha512-eUW3LN3fq8rXnjEUeI3D2QONYdLsU3yYQm4jxlErs2h4cfwrFjgf19VSUFVmm6LrFbbQ0OnDVPeVLL6iOwDw2w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.29.2" + } + }, + "node_modules/@tiptap/extension-heading": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.29.2.tgz", + "integrity": "sha512-6W4aIy70Mh7BNlbG9zZ5FBLhJhU2UUEzgZJ/jwYSCcB30o8McLxJSEjhtoHiX8R78Ah2/JzBGvIe5olZlbeE4A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.29.2" + } + }, + "node_modules/@tiptap/extension-horizontal-rule": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.29.2.tgz", + "integrity": "sha512-8/ZPzbB9X85Mc9/7xVLZupQKBr2UVcQTGr512xtqMW+XkCQRHCph46tRo828YE13IMWI5fWn/FaNCqXG9cULSw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.29.2", + "@tiptap/pm": "3.29.2" + } + }, + "node_modules/@tiptap/extension-italic": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.29.2.tgz", + "integrity": "sha512-iH63V/5wsaMnY4Jz0+meaAGhaec4AiOzOduzl6ZZr5IyGhZ1kthyW84ELt0dyLI3hNceAUhaNWc+I7+vX0aoXA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.29.2" + } + }, + "node_modules/@tiptap/extension-link": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.29.2.tgz", + "integrity": "sha512-DcVer5SqrexKCEP6Ip1UPxJUMvcRCCItSv0wxoGytanrimBh2smvcg6X0DWnjlsi5H0updhyl+atYCmmQXUIXA==", + "license": "MIT", + "dependencies": { + "linkifyjs": "^4.3.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.29.2", + "@tiptap/pm": "3.29.2" + } + }, + "node_modules/@tiptap/extension-list": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.29.2.tgz", + "integrity": "sha512-WPZ9BHAPT6QeIm1vdVkuoOWvy9a8/EZeJwV2VhU8LXyTAttvzyj4rsbbHyJWvYWlUSTt/QF2AZ2zhKo7u1w3/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.29.2", + "@tiptap/pm": "3.29.2" + } + }, + "node_modules/@tiptap/extension-list-item": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.29.2.tgz", + "integrity": "sha512-s8vBVHHFT0Qpu7CzAZ7S1kYmSiVaDvUNvMNcZUWnxj6VPfiwmx0eXd9FsjePrRCMoM5tFmPnhFTHDxY3D/eZeQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.29.2" + } + }, + "node_modules/@tiptap/extension-list-keymap": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.29.2.tgz", + "integrity": "sha512-R+3k8OLnxdCH7Xy9ieOwUt5m2Je74u8mikothGmsYVO2Zyq48fIbmZ+X6RBPCu7DBOI2FIUhHEFbKQeDWvDNmA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.29.2" + } + }, + "node_modules/@tiptap/extension-ordered-list": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.29.2.tgz", + "integrity": "sha512-ndCunC+UsYOpkOtL7vGnDz21UNa45WUlcO9wMT1fbuYow2QnRhsuMlCWENXI52YPPARWuQ0RDgN7q6TaxPERBg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.29.2" + } + }, + "node_modules/@tiptap/extension-paragraph": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.29.2.tgz", + "integrity": "sha512-7qJj5YTr11vvjNgjDN1ypOfwTovc0QOCYcit/rskeuVgnmQZOZQzC/BbyKLLG7UGnpRLemU/mEGbW9pAqjAXkQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.29.2" + } + }, + "node_modules/@tiptap/extension-strike": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.29.2.tgz", + "integrity": "sha512-aEvLAbddUQZ+FukCreV3q4G2HfNI+odE7E9U+wbq6XsSWKyo8/pDu1muz+TFKNre4blSMOQ3JQmw5UeHDKy+fg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.29.2" + } + }, + "node_modules/@tiptap/extension-text": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.29.2.tgz", + "integrity": "sha512-Ubko45JWWHe8glBt2PiGNF8hcbys/JNalFhiR7Y1X4iOOtAxAKJJxh3+eq+//NTlGuBPdWGp7zw8EEUp7anjKA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.29.2" + } + }, + "node_modules/@tiptap/extension-underline": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.29.2.tgz", + "integrity": "sha512-K7XwH/xS/5AIREWQ00VTEf/W5U0olp7j6wwit7cdd/8nHv6h6AGr1+iEApHKoLXWQZLfGQKzJlT9W61LAl+fHA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.29.2" + } + }, + "node_modules/@tiptap/extensions": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.29.2.tgz", + "integrity": "sha512-BCz+FCAChSYtUe4BFj97HEO+nSK+J7GxbJgZG4Hg7DT/gI+hRyeNndU8efiQAx3WGdzsFi3UxRpcF1tTQM7iMQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.29.2", + "@tiptap/pm": "3.29.2" + } + }, + "node_modules/@tiptap/pm": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.29.2.tgz", + "integrity": "sha512-GCOme7xHaS+DSoaA4CDcAD3l6JyBlvZhvCyfsy2Vp6j8tEoBkZWio7soYVosmlyn7zq8/64VeFZP5s47yfG7fQ==", + "license": "MIT", + "dependencies": { + "prosemirror-changeset": "^2.4.1", + "prosemirror-commands": "^1.7.1", + "prosemirror-dropcursor": "^1.8.2", + "prosemirror-gapcursor": "^1.4.1", + "prosemirror-history": "^1.5.0", + "prosemirror-inputrules": "^1.5.1", + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.11", + "prosemirror-schema-list": "^1.5.1", + "prosemirror-state": "^1.4.4", + "prosemirror-tables": "^1.8.5", + "prosemirror-transform": "^1.12.0", + "prosemirror-view": "^1.41.9" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/starter-kit": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.29.2.tgz", + "integrity": "sha512-oTu0tysiqk4zgjEtxRHjAQgxUKaAevZwueOWwSWubHdokqp7SpcbE5n9USJv89HKuTUDm3GjnQH6q8HNn/2DsA==", + "license": "MIT", + "dependencies": { + "@tiptap/core": "^3.29.2", + "@tiptap/extension-blockquote": "^3.29.2", + "@tiptap/extension-bold": "^3.29.2", + "@tiptap/extension-bullet-list": "^3.29.2", + "@tiptap/extension-code": "^3.29.2", + "@tiptap/extension-code-block": "^3.29.2", + "@tiptap/extension-document": "^3.29.2", + "@tiptap/extension-dropcursor": "^3.29.2", + "@tiptap/extension-gapcursor": "^3.29.2", + "@tiptap/extension-hard-break": "^3.29.2", + "@tiptap/extension-heading": "^3.29.2", + "@tiptap/extension-horizontal-rule": "^3.29.2", + "@tiptap/extension-italic": "^3.29.2", + "@tiptap/extension-link": "^3.29.2", + "@tiptap/extension-list": "^3.29.2", + "@tiptap/extension-list-item": "^3.29.2", + "@tiptap/extension-list-keymap": "^3.29.2", + "@tiptap/extension-ordered-list": "^3.29.2", + "@tiptap/extension-paragraph": "^3.29.2", + "@tiptap/extension-strike": "^3.29.2", + "@tiptap/extension-text": "^3.29.2", + "@tiptap/extension-underline": "^3.29.2", + "@tiptap/extensions": "^3.29.2", + "@tiptap/pm": "^3.29.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tursodatabase/database": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@tursodatabase/database/-/database-0.7.1.tgz", + "integrity": "sha512-RPEkpAoxKk0aY8spsUpT0MID7AUq7CHnqHOkA0zTG546XPkP/mkkQr1ZwpUer69z4BItSaDq+OQ3XrxVOFfsHg==", + "license": "MIT", + "dependencies": { + "@tursodatabase/database-common": "^0.7.1" + }, + "optionalDependencies": { + "@tursodatabase/database-darwin-arm64": "0.7.1", + "@tursodatabase/database-linux-arm64-gnu": "0.7.1", + "@tursodatabase/database-linux-x64-gnu": "0.7.1", + "@tursodatabase/database-win32-x64-msvc": "0.7.1" + } + }, + "node_modules/@tursodatabase/database-common": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@tursodatabase/database-common/-/database-common-0.7.1.tgz", + "integrity": "sha512-tyEKiRJDZSqLTbJQYhvY9Q2pGbBMwhKb8YS5T9TNOaDy1HvZMDbiypYqvtBw04f02QladUIsmpVBlsmFDb+RFA==", + "license": "MIT" + }, + "node_modules/@tursodatabase/database-darwin-arm64": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@tursodatabase/database-darwin-arm64/-/database-darwin-arm64-0.7.1.tgz", + "integrity": "sha512-lTki3P+yH5GawkBL2zmc0hU7nJQ92WCiKlb3XDfRpkX6UukwmnuAZAYM0oWK5rbOzBd0plZUGdItZGjwM+lPPw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@tursodatabase/database-linux-arm64-gnu": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@tursodatabase/database-linux-arm64-gnu/-/database-linux-arm64-gnu-0.7.1.tgz", + "integrity": "sha512-28dQmMUm1Nzvc2B6lRz6sbIRt5bJBVrnRdy2LpEUnYbU1aTvgLsUgfl4NQONbaaKTGzeKdxe8wONjfwkPbgsaA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@tursodatabase/database-linux-x64-gnu": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@tursodatabase/database-linux-x64-gnu/-/database-linux-x64-gnu-0.7.1.tgz", + "integrity": "sha512-8gENsVswYpwNeuRv/MQJ+aqqT0cwRaDxoaPCeST4IDfzLihl8C5qOrzqJ315pEOCquMKQNVsH8EGPmdjJd7l4g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@tursodatabase/database-win32-x64-msvc": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@tursodatabase/database-win32-x64-msvc/-/database-win32-x64-msvc-0.7.1.tgz", + "integrity": "sha512-+O3k4VPTlgOvzcRfogep8Tsf6MjwZ9BAzC7QD2QAIYZUvraqcXvv+dZTgZnkRJ6XI5Fu/3ePr8a6SkqbXjhoMA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/nodemailer": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz", + "integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argon2": { + "version": "0.45.1", + "resolved": "https://registry.npmjs.org/argon2/-/argon2-0.45.1.tgz", + "integrity": "sha512-skm+/WCjkGqCQxF7FG1LuZXM5yvbFjgbfiCGsud2oLgaDhh6b6dbH0b1EkghbM+xx4Bj8Ape+KKgixoIlWZicQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@phc/format": "^1.0.0", + "cross-env": "^10.1.0", + "node-addon-api": "^8.9.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", + "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^8.0.0", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cross-env": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", + "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", + "license": "MIT", + "dependencies": { + "@epic-web/invariant": "^1.0.0", + "cross-spawn": "^7.0.6" + }, + "bin": { + "cross-env": "dist/bin/cross-env.js", + "cross-env-shell": "dist/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.2.tgz", + "integrity": "sha512-DObPPAfdtFbXjxLqK8s2Xk9ZuWz5+ZoFEhC7J76es4GU/rEiXwHTmbImoCdyoCOcBH1UF3+Cz6Z2sYD4hyl5TA==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.0.tgz", + "integrity": "sha512-GQ/7RN8uOtEfNpzZzBMTzW9JBcX42oaSVtPzdF+6cEL8pqIL094iUpr9jzYGn4O4P/1S60dJ6izyT8F4LYARng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/linkifyjs": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz", + "integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==", + "license": "MIT" + }, + "node_modules/lint-staged": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.4.0.tgz", + "integrity": "sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^14.0.3", + "listr2": "^9.0.5", + "picomatch": "^4.0.3", + "string-argv": "^0.3.2", + "tinyexec": "^1.0.4", + "yaml": "^2.8.2" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, + "engines": { + "node": ">=20.17" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" + } + }, + "node_modules/listr2": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", + "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^5.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-addon-api": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz", + "integrity": "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/nodemailer": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz", + "integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/openai": { + "version": "6.49.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.49.0.tgz", + "integrity": "sha512-aYCc0C6L864eR6WSYIwQGyXriw/nIyZx0ObvhzOEVuk0zoBDpynjSbrionWI7q65B5H8jJX0DXR9snEzM6bfPg==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/credential-provider-node": ">=3.972.0 <4", + "@smithy/hash-node": ">=4.3.0 <5", + "@smithy/signature-v4": ">=5.4.0 <6", + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-provider-node": { + "optional": true + }, + "@smithy/hash-node": { + "optional": true + }, + "@smithy/signature-v4": { + "optional": true + }, + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/opentype.js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/opentype.js/-/opentype.js-2.0.0.tgz", + "integrity": "sha512-kCyjv6xdDY1W/jLWZ/L3QhhTlKUqDZMQ5+Jdlw12b3dXkKNpYBqqlMMj0YDQPShWFTMwgZI1hG14kN3XUDSg/A==", + "dev": true, + "license": "MIT", + "bin": { + "ot": "bin/ot" + } + }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "license": "MIT" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prosemirror-changeset": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz", + "integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==", + "license": "MIT", + "dependencies": { + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-commands": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz", + "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-dropcursor": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.3.tgz", + "integrity": "sha512-FoYbsJR8gK+DGlqhNoE29Loa38eIZPzQRIb1VMaDNBoo4OLP6vVof/jR8qFY/6XvUd6Dhug8MDCHl2a/h8RTfQ==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0", + "prosemirror-view": "^1.1.0" + } + }, + "node_modules/prosemirror-gapcursor": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz", + "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.0.0", + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0" + } + }, + "node_modules/prosemirror-history": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", + "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-inputrules": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", + "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-model": { + "version": "1.25.11", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.11.tgz", + "integrity": "sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==", + "license": "MIT", + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-schema-list": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.7.3" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", + "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.4" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz", + "integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.42.2", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.2.tgz", + "integrity": "sha512-Pdg0l5kXm8aLDquFAnQFTCITg0q44sLqBlHlpsVLD9segdOao8TOfQdAhCrCXyVgPSRr6UDDROOIWA3bIrN9YQ==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.25.8", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", + "license": "MIT" + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", + "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/sharp": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/slice-ansi": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", + "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, + "node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svelte": { + "version": "5.56.8", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.8.tgz", + "integrity": "sha512-PY8LOw7xP6c8IOiVqdo0sbbZVYhXRSfklOQLAUyGBKqjTX0wx/z4l/9J+PmBpmlLnxzEb1NqltxQ5/wZme/Cmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.12", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.3.tgz", + "integrity": "sha512-DHdTCGX62R0fCxBEaT+USdASAnoaRBaaNczkRJl0K7o3WyoCeVUbVxo6fKqpOll/B+WMWCsiFK0eFrJSNBKZIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "^0.2.0", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yocto-spinner": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-spinner/-/yocto-spinner-1.2.2.tgz", + "integrity": "sha512-DODGl1wJjA/s5pnJFKau9lIYHT81lnhob1i3e1TjxZRxEhWRKl74nTbWE6H5KlkViQQTo/Z29YFdxzTZAMY3ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": ">=18.19" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } } diff --git a/package.json b/package.json index cdb9718..e01c246 100644 --- a/package.json +++ b/package.json @@ -1,82 +1,94 @@ { - "name": "shd-build", - "private": true, - "version": "0.0.1", - "type": "module", - "scripts": { - "dev": "dotenvx run -f .env.development -- vite dev --host 0.0.0.0", - "build:app": "vite build", - "build:staging": "dotenvx run -f .env.staging -- vite build", - "build": "dotenvx run -f .env.production -- vite build", - "brand:render": "node scripts/render-brand-assets.mjs", - "start": "node build", - "preview": "node build", - "db:migrate": "dotenvx run -f .env.development -- node scripts/db-migrate.mjs", - "db:migrate:staging": "dotenvx run -f .env.staging -- node scripts/db-migrate.mjs", - "db:migrate:production": "dotenvx run -f .env.production -- node scripts/db-migrate.mjs", - "db:seed-users": "dotenvx run -f .env.development -- node scripts/seed-initial-users.mjs", - "db:seed-users:reset": "dotenvx run -f .env.development -- node scripts/seed-initial-users.mjs --reset-passwords", - "db:backup": "dotenvx run -f .env.development -- node scripts/db-backup.mjs", - "db:backup:staging": "dotenvx run -f .env.staging -- node scripts/db-backup.mjs", - "db:backup:production": "dotenvx run -f .env.production -- node scripts/db-backup.mjs", - "db:restore": "dotenvx run -f .env.development -- node scripts/db-restore.mjs", - "db:restore:production": "dotenvx run -f .env.production -- node scripts/db-restore.mjs", - "isac:export": "dotenvx run -f .env.development -- node scripts/isac-data-export.mjs", - "isac:import": "dotenvx run -f .env.development -- node scripts/isac-data-import.mjs", - "isac:attributes:import": "dotenvx run -f .env.development -- node scripts/import-resurgence-attributes.mjs", - "isac:export:staging": "dotenvx run -f .env.staging -- node scripts/isac-data-export.mjs", - "images:export": "dotenvx run -f .env.development -- node scripts/managed-images-export.mjs", - "images:import": "dotenvx run -f .env.development -- node scripts/managed-images-import.mjs", - "images:import:staging": "dotenvx run -f .env.staging -- node scripts/managed-images-import.mjs", - "images:import:production": "dotenvx run -f .env.production -- node scripts/managed-images-import.mjs", - "stage:bootstrap:export": "dotenvx run -f .env.development -- node scripts/stage-bootstrap-export.mjs", - "production:bootstrap:export": "dotenvx run -f .env.development -- node scripts/environment-bootstrap-export.mjs production", - "notifications:digest": "dotenvx run -f .env.development -- node scripts/notification-digest.mjs", - "notifications:digest:production": "dotenvx run -f .env.production -- node scripts/notification-digest.mjs", - "notifications:oversight": "dotenvx run -f .env.development -- node scripts/oversight-notification-dispatch.mjs", - "notifications:oversight:production": "dotenvx run -f .env.production -- node scripts/oversight-notification-dispatch.mjs", - "notifications:system": "dotenvx run -f .env.development -- node scripts/system-notification-dispatch.mjs", - "notifications:system:production": "dotenvx run -f .env.production -- node scripts/system-notification-dispatch.mjs", - "notifications:seed-test": "dotenvx run -f .env.development -- node scripts/seed-test-notifications.mjs", - "assets:trim-icons": "node scripts/trim-static-icons.mjs", - "env:encrypt": "node scripts/env-files.mjs encrypt", - "env:decrypt": "node scripts/env-files.mjs decrypt", - "env:encrypt:development": "dotenvx encrypt -f .env.development", - "env:decrypt:development": "dotenvx decrypt -f .env.development", - "env:encrypt:staging": "dotenvx encrypt -f .env.staging", - "env:decrypt:staging": "dotenvx decrypt -f .env.staging", - "env:sync:staging-credentials": "dotenvx run -f .env.development -- node scripts/sync-staging-credentials.mjs", - "env:encrypt:production": "dotenvx encrypt -f .env.production", - "env:decrypt:production": "dotenvx decrypt -f .env.production", - "env:stage": "node scripts/env-files.mjs stage", - "prepare": "svelte-kit sync || echo ''", - "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", - "test": "vitest run", - "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" - }, - "devDependencies": { - "@dotenvx/dotenvx": "^2.17.4", - "@sveltejs/adapter-node": "^5.5.7", - "@sveltejs/kit": "^2.63.0", - "@sveltejs/vite-plugin-svelte": "^7.1.2", - "@types/node": "^26.1.1", - "@types/nodemailer": "^8.0.1", - "opentype.js": "^2.0.0", - "svelte": "^5.56.1", - "svelte-check": "^4.6.0", - "typescript": "^6.0.3", - "vite": "^8.0.16", - "vitest": "^4.1.10" - }, - "dependencies": { - "@fontsource/barlow": "^5.3.0", - "@fontsource/source-serif-4": "^5.3.0", - "@simplewebauthn/browser": "^13.3.0", - "@simplewebauthn/server": "^13.3.2", - "@tursodatabase/database": "^0.7.1", - "argon2": "^0.45.1", - "nodemailer": "^9.0.3", - "openai": "^6.49.0", - "sharp": "^0.35.3" - } + "name": "shd-build", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "dotenvx run -f .env.development -- vite dev --host 0.0.0.0", + "build:app": "vite build", + "build:staging": "dotenvx run -f .env.staging -- vite build", + "build": "dotenvx run -f .env.production -- vite build", + "brand:render": "node scripts/render-brand-assets.mjs", + "start": "node build", + "preview": "node build", + "db:migrate": "dotenvx run -f .env.development -- node scripts/db-migrate.mjs", + "db:migrate:staging": "dotenvx run -f .env.staging -- node scripts/db-migrate.mjs", + "db:migrate:production": "dotenvx run -f .env.production -- node scripts/db-migrate.mjs", + "db:seed-users": "dotenvx run -f .env.development -- node scripts/seed-initial-users.mjs", + "db:seed-users:reset": "dotenvx run -f .env.development -- node scripts/seed-initial-users.mjs --reset-passwords", + "db:backup": "dotenvx run -f .env.development -- node scripts/db-backup.mjs", + "db:backup:staging": "dotenvx run -f .env.staging -- node scripts/db-backup.mjs", + "db:backup:production": "dotenvx run -f .env.production -- node scripts/db-backup.mjs", + "db:restore": "dotenvx run -f .env.development -- node scripts/db-restore.mjs", + "db:restore:production": "dotenvx run -f .env.production -- node scripts/db-restore.mjs", + "article:export": "dotenvx run -f .env.development -- node scripts/article-transfer.mjs export", + "isac:export": "dotenvx run -f .env.development -- node scripts/isac-data-export.mjs", + "isac:import": "dotenvx run -f .env.development -- node scripts/isac-data-import.mjs", + "isac:attributes:import": "dotenvx run -f .env.development -- node scripts/import-resurgence-attributes.mjs", + "isac:export:staging": "dotenvx run -f .env.staging -- node scripts/isac-data-export.mjs", + "images:export": "dotenvx run -f .env.development -- node scripts/managed-images-export.mjs", + "images:import": "dotenvx run -f .env.development -- node scripts/managed-images-import.mjs", + "images:import:staging": "dotenvx run -f .env.staging -- node scripts/managed-images-import.mjs", + "images:import:production": "dotenvx run -f .env.production -- node scripts/managed-images-import.mjs", + "stage:bootstrap:export": "dotenvx run -f .env.development -- node scripts/stage-bootstrap-export.mjs", + "production:bootstrap:export": "dotenvx run -f .env.development -- node scripts/environment-bootstrap-export.mjs production", + "notifications:digest": "dotenvx run -f .env.development -- node scripts/notification-digest.mjs", + "notifications:digest:production": "dotenvx run -f .env.production -- node scripts/notification-digest.mjs", + "notifications:oversight": "dotenvx run -f .env.development -- node scripts/oversight-notification-dispatch.mjs", + "notifications:oversight:production": "dotenvx run -f .env.production -- node scripts/oversight-notification-dispatch.mjs", + "notifications:system": "dotenvx run -f .env.development -- node scripts/system-notification-dispatch.mjs", + "notifications:system:production": "dotenvx run -f .env.production -- node scripts/system-notification-dispatch.mjs", + "notifications:seed-test": "dotenvx run -f .env.development -- node scripts/seed-test-notifications.mjs", + "assets:trim-icons": "node scripts/trim-static-icons.mjs", + "env:encrypt": "node scripts/env-files.mjs encrypt", + "env:decrypt": "node scripts/env-files.mjs decrypt", + "env:encrypt:development": "dotenvx encrypt -f .env.development", + "env:decrypt:development": "dotenvx decrypt -f .env.development", + "env:encrypt:staging": "dotenvx encrypt -f .env.staging", + "env:decrypt:staging": "dotenvx decrypt -f .env.staging", + "env:sync:staging-credentials": "dotenvx run -f .env.development -- node scripts/sync-staging-credentials.mjs", + "env:encrypt:production": "dotenvx encrypt -f .env.production", + "env:decrypt:production": "dotenvx decrypt -f .env.production", + "env:stage": "node scripts/env-files.mjs stage", + "prepare": "husky", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "test": "vitest run", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" + }, + "lint-staged": { + "**/*": "prettier --write --ignore-unknown" + }, + "devDependencies": { + "@dotenvx/dotenvx": "^2.17.4", + "@sveltejs/adapter-node": "^5.5.7", + "@sveltejs/kit": "^2.63.0", + "@sveltejs/vite-plugin-svelte": "^7.1.2", + "@types/node": "^26.1.1", + "@types/nodemailer": "^8.0.1", + "husky": "^9.1.7", + "lint-staged": "^16.4.0", + "opentype.js": "^2.0.0", + "prettier": "3.9.6", + "svelte": "^5.56.1", + "svelte-check": "^4.6.0", + "typescript": "^6.0.3", + "vite": "^8.0.16", + "vitest": "^4.1.10" + }, + "dependencies": { + "@fontsource/barlow": "^5.3.0", + "@fontsource/source-serif-4": "^5.3.0", + "@simplewebauthn/browser": "^13.3.0", + "@simplewebauthn/server": "^13.3.2", + "@tiptap/core": "3.29.2", + "@tiptap/extension-link": "3.29.2", + "@tiptap/extensions": "3.29.2", + "@tiptap/pm": "3.29.2", + "@tiptap/starter-kit": "3.29.2", + "@tursodatabase/database": "^0.7.1", + "argon2": "^0.45.1", + "nodemailer": "^9.0.3", + "openai": "^6.49.0", + "sharp": "^0.35.3" + } } diff --git a/scripts/article-transfer.mjs b/scripts/article-transfer.mjs new file mode 100644 index 0000000..95e2b90 --- /dev/null +++ b/scripts/article-transfer.mjs @@ -0,0 +1,425 @@ +import { connect } from '@tursodatabase/database'; +import { spawnSync } from 'node:child_process'; +import { createHash, randomUUID } from 'node:crypto'; +import { mkdir, readFile, link, rm, writeFile } from 'node:fs/promises'; +import { basename, dirname, join, resolve } from 'node:path'; +import process from 'node:process'; + +const mode = process.argv[2]; +const subject = process.argv[3]; +const confirmation = '--confirm-article-import'; +const databasePath = resolve( + process.env.TDR_DATABASE_PATH || './data/shd-build-development.db', +); +const imageDirectory = resolve( + process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons', +); +const UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const STORAGE_KEY_PATTERN = /^library-[0-9a-f-]{36}\.webp$/i; + +const failUsage = () => { + console.error( + 'Usage: node scripts/article-transfer.mjs export [output.json]', + ); + console.error( + ` or: ARTICLE_IMPORT_ENABLED=true node scripts/article-transfer.mjs import ${confirmation}`, + ); + process.exit(2); +}; + +if (!['export', 'import'].includes(mode) || !subject) failUsage(); + +const hash = (value) => createHash('sha256').update(value).digest('hex'); +const quoteIdentifier = (identifier) => + `"${String(identifier).replaceAll('"', '""')}"`; +const isRecord = (value) => + Boolean(value) && typeof value === 'object' && !Array.isArray(value); + +async function insertRow(database, table, row) { + const columns = Object.keys(row); + if (!columns.length) throw new Error(`Cannot insert an empty ${table} row.`); + await ( + await database.prepare( + `INSERT INTO ${quoteIdentifier(table)} (${columns.map(quoteIdentifier).join(',')}) VALUES (${columns.map(() => '?').join(',')})`, + ) + ).run(...columns.map((column) => row[column])); +} + +async function exportArticle() { + const database = await connect(databasePath); + const article = await ( + await database.prepare( + `SELECT * FROM articles WHERE id=? OR slug=? ORDER BY CASE WHEN id=? THEN 0 ELSE 1 END LIMIT 1`, + ) + ).get(subject, subject, subject); + if (!article) throw new Error('Article not found.'); + if (article.kind !== 'news') + throw new Error('Only News articles can use this transfer command.'); + if (article.deleted_at) + throw new Error('Deleted News articles cannot be transferred.'); + const external = await ( + await database.prepare( + 'SELECT article_id FROM external_news_sources WHERE article_id=?', + ) + ).get(article.id); + if (external) + throw new Error( + 'Syndicated News articles must come from their source feed.', + ); + const embeds = await ( + await database.prepare( + `SELECT COUNT(*) AS count FROM article_revision_build_embeds + WHERE revision_id IN (SELECT id FROM article_revisions WHERE article_id=?)`, + ) + ).get(article.id); + if (Number(embeds?.count ?? 0) > 0) + throw new Error('News article transfers cannot contain Build embeds.'); + + const owner = await ( + await database.prepare('SELECT id,username FROM users WHERE id=?') + ).get(article.owner_id); + if (!owner?.username) + throw new Error('The article owner does not have a transferable username.'); + const draft = await ( + await database.prepare('SELECT * FROM article_drafts WHERE article_id=?') + ).get(article.id); + const revisions = await ( + await database.prepare( + 'SELECT * FROM article_revisions WHERE article_id=? ORDER BY revision_number', + ) + ).all(article.id); + if (!draft || !revisions.length) + throw new Error('The article is missing its draft or revision history.'); + const revisionIds = revisions.map((revision) => String(revision.id)); + const placeholders = revisionIds.map(() => '?').join(','); + const revisionTags = await ( + await database.prepare( + `SELECT * FROM article_revision_tags WHERE revision_id IN (${placeholders}) ORDER BY revision_id,ordinal`, + ) + ).all(...revisionIds); + const media = await ( + await database.prepare( + 'SELECT * FROM article_media WHERE article_id=? ORDER BY image_id', + ) + ).all(article.id); + const search = await ( + await database.prepare('SELECT * FROM article_search WHERE article_id=?') + ).get(article.id); + const imageIds = [...new Set(media.map((row) => String(row.image_id)))]; + const images = imageIds.length + ? await ( + await database.prepare( + `SELECT * FROM managed_images WHERE id IN (${imageIds.map(() => '?').join(',')}) ORDER BY id`, + ) + ).all(...imageIds) + : []; + if (images.length !== imageIds.length) + throw new Error('The article references missing managed-image metadata.'); + const files = []; + for (const image of images) { + const storageKey = String(image.storage_key); + if (!STORAGE_KEY_PATTERN.test(storageKey)) + throw new Error('The article uses an unsupported managed-image key.'); + const bytes = await readFile(join(imageDirectory, storageKey)); + if (bytes.byteLength !== Number(image.byte_size)) + throw new Error(`Managed image ${image.id} has an invalid byte size.`); + files.push({ + storageKey, + byteSize: bytes.byteLength, + sha256: hash(bytes), + contentBase64: bytes.toString('base64'), + }); + } + + const bundle = { + format: 'shd-build-article-transfer', + version: 1, + createdAt: new Date().toISOString(), + owner: { sourceId: String(owner.id), username: String(owner.username) }, + article, + draft, + revisions, + revisionTags, + media, + search: search ?? null, + images, + files, + }; + const timestamp = new Date() + .toISOString() + .replaceAll(':', '-') + .replace(/\.\d{3}Z$/, 'Z'); + const outputPath = resolve( + process.argv[4] || + `./data/backups/article-${String(article.slug)}-${timestamp}.json`, + ); + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, `${JSON.stringify(bundle, null, 2)}\n`, { + encoding: 'utf8', + flag: 'wx', + mode: 0o600, + }); + console.log( + `Exported News article "${article.slug}" with ${revisions.length} revisions and ${files.length} images.`, + ); + console.log(`Article bundle: ${outputPath}`); +} + +function validateBundle(bundle) { + if ( + !isRecord(bundle) || + bundle.format !== 'shd-build-article-transfer' || + bundle.version !== 1 || + !isRecord(bundle.owner) || + !isRecord(bundle.article) || + !isRecord(bundle.draft) || + !Array.isArray(bundle.revisions) || + !Array.isArray(bundle.revisionTags) || + !Array.isArray(bundle.media) || + !Array.isArray(bundle.images) || + !Array.isArray(bundle.files) || + bundle.article.kind !== 'news' || + !UUID_PATTERN.test(String(bundle.article.id)) || + !bundle.owner.username || + !bundle.revisions.length + ) { + throw new Error( + 'The selected file is not a supported News article bundle.', + ); + } + const articleId = String(bundle.article.id); + if ( + bundle.draft.article_id !== articleId || + bundle.revisions.some( + (revision) => + !isRecord(revision) || + revision.article_id !== articleId || + !UUID_PATTERN.test(String(revision.id)), + ) || + bundle.media.some( + (row) => !isRecord(row) || row.article_id !== articleId, + ) || + bundle.images.some( + (image) => + !isRecord(image) || + !UUID_PATTERN.test(String(image.id)) || + !STORAGE_KEY_PATTERN.test(String(image.storage_key)) || + image.source_entity_type !== null || + image.source_entity_id !== null, + ) + ) { + throw new Error('The News article bundle contains inconsistent records.'); + } + const imageIds = new Set(bundle.images.map((image) => String(image.id))); + const files = new Map(); + for (const file of bundle.files) { + if ( + !isRecord(file) || + !STORAGE_KEY_PATTERN.test(String(file.storageKey)) || + !Number.isInteger(file.byteSize) || + file.byteSize <= 0 || + typeof file.sha256 !== 'string' || + !/^[0-9a-f]{64}$/.test(file.sha256) || + typeof file.contentBase64 !== 'string' + ) { + throw new Error( + 'The News article bundle contains an invalid image file.', + ); + } + const bytes = Buffer.from(file.contentBase64, 'base64'); + if (bytes.byteLength !== file.byteSize || hash(bytes) !== file.sha256) + throw new Error('The News article bundle contains a corrupt image file.'); + files.set(String(file.storageKey), bytes); + } + if ( + bundle.images.some((image) => !files.has(String(image.storage_key))) || + bundle.media.some((row) => !imageIds.has(String(row.image_id))) + ) { + throw new Error('The News article bundle is missing image dependencies.'); + } + const revisionIds = new Set( + bundle.revisions.map((revision) => String(revision.id)), + ); + if ( + !revisionIds.has(String(bundle.article.published_revision_id)) || + (bundle.article.pending_revision_id && + !revisionIds.has(String(bundle.article.pending_revision_id))) || + bundle.revisionTags.some((row) => !revisionIds.has(String(row.revision_id))) + ) { + throw new Error('The News article bundle has invalid revision pointers.'); + } + return files; +} + +async function importArticle() { + if ( + process.env.ARTICLE_IMPORT_ENABLED !== 'true' || + !process.argv.includes(confirmation) + ) { + failUsage(); + } + const bundlePath = resolve(subject); + const bundle = JSON.parse(await readFile(bundlePath, 'utf8')); + const files = validateBundle(bundle); + + const safetyBackup = spawnSync(process.execPath, ['scripts/db-backup.mjs'], { + cwd: process.cwd(), + stdio: 'inherit', + env: process.env, + }); + if (safetyBackup.error) throw safetyBackup.error; + if (safetyBackup.status !== 0) + throw new Error('Article import stopped because its safety backup failed.'); + + const database = await connect(databasePath); + const existing = await ( + await database.prepare( + 'SELECT id,slug FROM articles WHERE id=? OR (kind=? AND slug=?)', + ) + ).get(bundle.article.id, bundle.article.kind, bundle.article.slug); + if (existing) { + if ( + existing.id === bundle.article.id && + existing.slug === bundle.article.slug + ) { + console.log(`News article "${existing.slug}" is already present.`); + return; + } + throw new Error('The target already contains a conflicting News article.'); + } + const owner = await ( + await database.prepare( + 'SELECT id,username FROM users WHERE id=? OR lower(username)=lower(?) ORDER BY CASE WHEN id=? THEN 0 ELSE 1 END LIMIT 1', + ) + ).get(bundle.owner.sourceId, bundle.owner.username, bundle.owner.sourceId); + if (!owner) + throw new Error( + `The target does not contain Agent "${bundle.owner.username}".`, + ); + const tagIds = [ + ...new Set(bundle.revisionTags.map((row) => String(row.tag_id))), + ]; + if (tagIds.length) { + const available = await ( + await database.prepare( + `SELECT COUNT(*) AS count FROM article_tags WHERE id IN (${tagIds.map(() => '?').join(',')})`, + ) + ).get(...tagIds); + if (Number(available?.count ?? 0) !== tagIds.length) + throw new Error('The target is missing one or more article tags.'); + } + const imageIds = bundle.images.map((image) => String(image.id)); + if (imageIds.length) { + const collision = await ( + await database.prepare( + `SELECT id FROM managed_images WHERE id IN (${imageIds.map(() => '?').join(',')}) OR storage_key IN (${imageIds.map(() => '?').join(',')}) LIMIT 1`, + ) + ).get(...imageIds, ...bundle.images.map((image) => image.storage_key)); + if (collision) + throw new Error('The target contains a conflicting managed image.'); + } + + await mkdir(imageDirectory, { recursive: true }); + const createdFiles = []; + let transactionStarted = false; + try { + for (const image of bundle.images) { + const storageKey = String(image.storage_key); + const temporaryPath = join( + imageDirectory, + `.${storageKey}.${randomUUID()}.tmp`, + ); + await writeFile(temporaryPath, files.get(storageKey), { + flag: 'wx', + mode: 0o600, + }); + const finalPath = join(imageDirectory, storageKey); + try { + await link(temporaryPath, finalPath); + createdFiles.push(finalPath); + } finally { + await rm(temporaryPath, { force: true }); + } + } + await database.exec('BEGIN IMMEDIATE'); + transactionStarted = true; + for (const image of bundle.images) { + await insertRow(database, 'managed_images', { + ...image, + created_by: owner.id, + }); + } + await insertRow(database, 'articles', { + ...bundle.article, + owner_id: owner.id, + visibility: 'private', + published_revision_id: null, + pending_revision_id: null, + }); + await insertRow(database, 'article_drafts', { + ...bundle.draft, + updated_by: owner.id, + }); + for (const revision of bundle.revisions) { + await insertRow(database, 'article_revisions', { + ...revision, + created_by: owner.id, + }); + } + for (const row of bundle.revisionTags) + await insertRow(database, 'article_revision_tags', row); + for (const row of bundle.media) { + await insertRow(database, 'article_media', { + ...row, + uploaded_by: owner.id, + }); + } + if (bundle.search) + await insertRow(database, 'article_search', bundle.search); + await ( + await database.prepare( + `UPDATE articles SET visibility=?,published_revision_id=?,pending_revision_id=? WHERE id=?`, + ) + ).run( + bundle.article.visibility, + bundle.article.published_revision_id, + bundle.article.pending_revision_id, + bundle.article.id, + ); + await insertRow(database, 'audit_log', { + id: randomUUID(), + actor_id: owner.id, + entity_type: 'editorial_content', + entity_id: bundle.article.id, + action: 'news.promoted', + before_json: null, + after_json: JSON.stringify({ + sourceBundle: basename(bundlePath), + slug: bundle.article.slug, + publishedRevisionId: bundle.article.published_revision_id, + revisions: bundle.revisions.length, + images: bundle.images.length, + }), + created_at: new Date().toISOString(), + }); + await database.exec('COMMIT'); + transactionStarted = false; + } catch (error) { + if (transactionStarted) { + try { + await database.exec('ROLLBACK'); + } catch { + // Preserve the import error. + } + } + for (const file of createdFiles) await rm(file, { force: true }); + throw error; + } + console.log( + `Imported News article "${bundle.article.slug}" with ${bundle.revisions.length} revisions and ${bundle.images.length} images for ${owner.username}.`, + ); +} + +if (mode === 'export') await exportArticle(); +else await importArticle(); diff --git a/scripts/data/resurgence-attributes.json b/scripts/data/resurgence-attributes.json index e5a383f..64d453e 100644 --- a/scripts/data/resurgence-attributes.json +++ b/scripts/data/resurgence-attributes.json @@ -1,40 +1,304 @@ [ - {"name":"Weapon Critical Hit Chance","unit":"%","type":"Offensive","category":"Weapon","calculationType":"Additive","gearRange":[2.4,6]}, - {"name":"Weapon Critical Hit Damage","unit":"%","type":"Offensive","category":"Weapon","calculationType":"Additive","gearRange":[4,10],"weaponRange":[8,20]}, - {"name":"Weapon Damage","unit":"%","type":"Offensive","category":"Weapon","calculationType":"Additive","gearRange":[2,5],"weaponRange":[4,10]}, - {"name":"Weapon Multi-Shot Chance","unit":"%","type":"Offensive","category":"Weapon","calculationType":"Additive","gearRange":[2,5],"weaponRange":[4,10]}, - {"name":"Skill Critical Chance","unit":"%","type":"Offensive","category":"Skill","calculationType":"Additive","gearRange":[2.4,6]}, - {"name":"Skill Critical Damage","unit":"%","type":"Offensive","category":"Skill","calculationType":"Additive","gearRange":[4,10],"weaponRange":[8,20]}, - {"name":"Skill Intensity","unit":"%","type":"Offensive","category":"Skill","calculationType":"Additive","gearRange":[2,5],"weaponRange":[4,10]}, - {"name":"Skill Multi-Shot Chance","unit":"%","type":"Offensive","category":"Skill","calculationType":"Additive","gearRange":[2,5],"weaponRange":[4,10]}, - {"name":"Headshot Damage","unit":"%","type":"Offensive","category":"Weapon","calculationType":"Additive","gearRange":[4.8,12],"weaponRange":[9.6,24]}, - {"name":"Max Health","unit":"%","type":"Defensive","category":"Character","calculationType":"Additive","gearRange":[4,10],"weaponRange":[8,20]}, - {"name":"Armor","unit":"%","type":"Defensive","category":"Character","calculationType":"Additive","gearRange":[4.66,11.66],"weaponRange":[9.33,23.33]}, - {"name":"Damage Reduction","unit":"%","type":"Defensive","category":"Character","calculationType":"Multiplicative","gearRange":[2,5],"weaponRange":[4,10]}, - {"name":"Received Healing","unit":"%","type":"Defensive","category":"Character","calculationType":"Additive","gearRange":[4.8,12],"weaponRange":[9.6,24]}, - {"name":"Damage to Elites","unit":"%","type":"Offensive","category":"Character","calculationType":"Additive","gearRange":[2.4,6],"weaponRange":[4.8,12]}, - {"name":"Signature Ability Charge Efficiency","unit":"%","type":"Offensive","category":"Skill","calculationType":"Additive","gearRange":[4,10]}, - {"name":"Skill Duration","unit":"%","type":"Offensive","category":"Skill","calculationType":"Additive","gearRange":[3.2,8]}, - {"name":"Skill Radius","unit":"%","type":"Offensive","category":"Skill","calculationType":"Additive","gearRange":[4,10]}, - {"name":"Skill Cooldown Recovery","unit":"%","type":"Offensive","category":"Skill","calculationType":"Additive","gearRange":[-5,-2]}, - {"name":"Weapon Blast Damage","unit":"%","type":"Offensive","category":"Weapon","calculationType":"Additive","weaponRange":[4,10]}, - {"name":"Weapon Piercing Damage","unit":"%","type":"Offensive","category":"Weapon","calculationType":"Additive","weaponRange":[4,10]}, - {"name":"Weapon Shredding Damage","unit":"%","type":"Offensive","category":"Weapon","calculationType":"Additive","weaponRange":[4,10]}, - {"name":"Skill Blast Damage","unit":"%","type":"Offensive","category":"Skill","calculationType":"Additive","weaponRange":[4,10]}, - {"name":"Skill Piercing Damage","unit":"%","type":"Offensive","category":"Skill","calculationType":"Additive","weaponRange":[4,10]}, - {"name":"Skill Shredding Damage","unit":"%","type":"Offensive","category":"Skill","calculationType":"Additive","weaponRange":[4,10]}, - {"name":"Rate of Fire","unit":"%","type":"Offensive","category":"Weapon","calculationType":"Additive"}, - {"name":"Reload Speed","unit":"%","type":"Offensive","category":"Weapon","calculationType":"Additive"}, - {"name":"Health","unit":"","type":"Defensive","category":"Character","calculationType":"Additive"}, - {"name":"Armor","unit":"","type":"Defensive","category":"Character","calculationType":"Additive"}, - {"name":"Movement Speed","unit":"%","type":"Defensive","category":"Character","calculationType":"Additive"}, - {"name":"Piercing Damage","unit":"","type":"Offensive","category":"Weapon","calculationType":"Additive"}, - {"name":"RPM","unit":"Shots","type":"Offensive","category":"Weapon","calculationType":"Additive"}, - {"name":"Optimal Range","unit":"m","type":"Offensive","category":"Weapon","calculationType":"Additive"}, - {"name":"ADS Speed","unit":"s","type":"Offensive","category":"Weapon","calculationType":"Additive"}, - {"name":"Reload Speed","unit":"s","type":"Offensive","category":"Weapon","calculationType":"Additive"}, - {"name":"Magazine Size","unit":"Shots","type":"Offensive","category":"Weapon","calculationType":"Additive"}, - {"name":"Handling Speed","unit":"s","type":"Offensive","category":"Weapon","calculationType":"Additive"}, - {"name":"Accuracy","unit":"","type":"Offensive","category":"Weapon","calculationType":"Additive"}, - {"name":"Vertical Stability","unit":"","type":"Offensive","category":"Weapon","calculationType":"Additive"} + { + "name": "Weapon Critical Hit Chance", + "unit": "%", + "type": "Offensive", + "category": "Weapon", + "calculationType": "Additive", + "gearRange": [2.4, 6] + }, + { + "name": "Weapon Critical Hit Damage", + "unit": "%", + "type": "Offensive", + "category": "Weapon", + "calculationType": "Additive", + "gearRange": [4, 10], + "weaponRange": [8, 20] + }, + { + "name": "Weapon Damage", + "unit": "%", + "type": "Offensive", + "category": "Weapon", + "calculationType": "Additive", + "gearRange": [2, 5], + "weaponRange": [4, 10] + }, + { + "name": "Weapon Multi-Shot Chance", + "unit": "%", + "type": "Offensive", + "category": "Weapon", + "calculationType": "Additive", + "gearRange": [2, 5], + "weaponRange": [4, 10] + }, + { + "name": "Skill Critical Chance", + "unit": "%", + "type": "Offensive", + "category": "Skill", + "calculationType": "Additive", + "gearRange": [2.4, 6] + }, + { + "name": "Skill Critical Damage", + "unit": "%", + "type": "Offensive", + "category": "Skill", + "calculationType": "Additive", + "gearRange": [4, 10], + "weaponRange": [8, 20] + }, + { + "name": "Skill Intensity", + "unit": "%", + "type": "Offensive", + "category": "Skill", + "calculationType": "Additive", + "gearRange": [2, 5], + "weaponRange": [4, 10] + }, + { + "name": "Skill Multi-Shot Chance", + "unit": "%", + "type": "Offensive", + "category": "Skill", + "calculationType": "Additive", + "gearRange": [2, 5], + "weaponRange": [4, 10] + }, + { + "name": "Headshot Damage", + "unit": "%", + "type": "Offensive", + "category": "Weapon", + "calculationType": "Additive", + "gearRange": [4.8, 12], + "weaponRange": [9.6, 24] + }, + { + "name": "Max Health", + "unit": "%", + "type": "Defensive", + "category": "Character", + "calculationType": "Additive", + "gearRange": [4, 10], + "weaponRange": [8, 20] + }, + { + "name": "Armor", + "unit": "%", + "type": "Defensive", + "category": "Character", + "calculationType": "Additive", + "gearRange": [4.66, 11.66], + "weaponRange": [9.33, 23.33] + }, + { + "name": "Damage Reduction", + "unit": "%", + "type": "Defensive", + "category": "Character", + "calculationType": "Multiplicative", + "gearRange": [2, 5], + "weaponRange": [4, 10] + }, + { + "name": "Received Healing", + "unit": "%", + "type": "Defensive", + "category": "Character", + "calculationType": "Additive", + "gearRange": [4.8, 12], + "weaponRange": [9.6, 24] + }, + { + "name": "Damage to Elites", + "unit": "%", + "type": "Offensive", + "category": "Character", + "calculationType": "Additive", + "gearRange": [2.4, 6], + "weaponRange": [4.8, 12] + }, + { + "name": "Signature Ability Charge Efficiency", + "unit": "%", + "type": "Offensive", + "category": "Skill", + "calculationType": "Additive", + "gearRange": [4, 10] + }, + { + "name": "Skill Duration", + "unit": "%", + "type": "Offensive", + "category": "Skill", + "calculationType": "Additive", + "gearRange": [3.2, 8] + }, + { + "name": "Skill Radius", + "unit": "%", + "type": "Offensive", + "category": "Skill", + "calculationType": "Additive", + "gearRange": [4, 10] + }, + { + "name": "Skill Cooldown Recovery", + "unit": "%", + "type": "Offensive", + "category": "Skill", + "calculationType": "Additive", + "gearRange": [-5, -2] + }, + { + "name": "Weapon Blast Damage", + "unit": "%", + "type": "Offensive", + "category": "Weapon", + "calculationType": "Additive", + "weaponRange": [4, 10] + }, + { + "name": "Weapon Piercing Damage", + "unit": "%", + "type": "Offensive", + "category": "Weapon", + "calculationType": "Additive", + "weaponRange": [4, 10] + }, + { + "name": "Weapon Shredding Damage", + "unit": "%", + "type": "Offensive", + "category": "Weapon", + "calculationType": "Additive", + "weaponRange": [4, 10] + }, + { + "name": "Skill Blast Damage", + "unit": "%", + "type": "Offensive", + "category": "Skill", + "calculationType": "Additive", + "weaponRange": [4, 10] + }, + { + "name": "Skill Piercing Damage", + "unit": "%", + "type": "Offensive", + "category": "Skill", + "calculationType": "Additive", + "weaponRange": [4, 10] + }, + { + "name": "Skill Shredding Damage", + "unit": "%", + "type": "Offensive", + "category": "Skill", + "calculationType": "Additive", + "weaponRange": [4, 10] + }, + { + "name": "Rate of Fire", + "unit": "%", + "type": "Offensive", + "category": "Weapon", + "calculationType": "Additive" + }, + { + "name": "Reload Speed", + "unit": "%", + "type": "Offensive", + "category": "Weapon", + "calculationType": "Additive" + }, + { + "name": "Health", + "unit": "", + "type": "Defensive", + "category": "Character", + "calculationType": "Additive" + }, + { + "name": "Armor", + "unit": "", + "type": "Defensive", + "category": "Character", + "calculationType": "Additive" + }, + { + "name": "Movement Speed", + "unit": "%", + "type": "Defensive", + "category": "Character", + "calculationType": "Additive" + }, + { + "name": "Piercing Damage", + "unit": "", + "type": "Offensive", + "category": "Weapon", + "calculationType": "Additive" + }, + { + "name": "RPM", + "unit": "Shots", + "type": "Offensive", + "category": "Weapon", + "calculationType": "Additive" + }, + { + "name": "Optimal Range", + "unit": "m", + "type": "Offensive", + "category": "Weapon", + "calculationType": "Additive" + }, + { + "name": "ADS Speed", + "unit": "s", + "type": "Offensive", + "category": "Weapon", + "calculationType": "Additive" + }, + { + "name": "Reload Speed", + "unit": "s", + "type": "Offensive", + "category": "Weapon", + "calculationType": "Additive" + }, + { + "name": "Magazine Size", + "unit": "Shots", + "type": "Offensive", + "category": "Weapon", + "calculationType": "Additive" + }, + { + "name": "Handling Speed", + "unit": "s", + "type": "Offensive", + "category": "Weapon", + "calculationType": "Additive" + }, + { + "name": "Accuracy", + "unit": "", + "type": "Offensive", + "category": "Weapon", + "calculationType": "Additive" + }, + { + "name": "Vertical Stability", + "unit": "", + "type": "Offensive", + "category": "Weapon", + "calculationType": "Additive" + } ] diff --git a/scripts/db-backup.mjs b/scripts/db-backup.mjs index df37647..eca63b0 100644 --- a/scripts/db-backup.mjs +++ b/scripts/db-backup.mjs @@ -3,19 +3,25 @@ import { mkdir, writeFile } from 'node:fs/promises'; import { basename, dirname, resolve } from 'node:path'; import process from 'node:process'; -const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build-development.db'); +const databasePath = resolve( + process.env.TDR_DATABASE_PATH || './data/shd-build-development.db', +); const requestedOutput = process.argv[2]; -const timestamp = new Date().toISOString().replaceAll(':', '-').replace(/\.\d{3}Z$/, 'Z'); +const timestamp = new Date() + .toISOString() + .replaceAll(':', '-') + .replace(/\.\d{3}Z$/, 'Z'); const databaseName = basename(databasePath).replace(/\.[^.]+$/, ''); const outputPath = resolve( - requestedOutput || `./data/backups/${databaseName}-${timestamp}.json` + requestedOutput || `./data/backups/${databaseName}-${timestamp}.json`, ); -const quoteIdentifier = (identifier) => `"${String(identifier).replaceAll('"', '""')}"`; +const quoteIdentifier = (identifier) => + `"${String(identifier).replaceAll('"', '""')}"`; await mkdir(dirname(outputPath), { recursive: true }); const database = await connect(databasePath); const tableRows = await ( - await database.prepare(` + await database.prepare(` SELECT name FROM sqlite_master WHERE type = 'table' @@ -27,23 +33,25 @@ const tableRows = await ( const tables = []; let totalRows = 0; for (const tableRow of tableRows) { - const name = String(tableRow.name); - const rows = await (await database.prepare(`SELECT * FROM ${quoteIdentifier(name)}`)).all(); - tables.push({ name, rowCount: rows.length, rows }); - totalRows += rows.length; + const name = String(tableRow.name); + const rows = await ( + await database.prepare(`SELECT * FROM ${quoteIdentifier(name)}`) + ).all(); + tables.push({ name, rowCount: rows.length, rows }); + totalRows += rows.length; } const backup = { - format: 'shd-build-database-backup', - version: 1, - createdAt: new Date().toISOString(), - sourceDatabase: basename(databasePath), - totalRows, - tables + format: 'shd-build-database-backup', + version: 1, + createdAt: new Date().toISOString(), + sourceDatabase: basename(databasePath), + totalRows, + tables, }; await writeFile(outputPath, `${JSON.stringify(backup, null, 2)}\n`, { - encoding: 'utf8', - flag: 'wx' + encoding: 'utf8', + flag: 'wx', }); console.log(`Backed up ${totalRows} rows from ${tables.length} tables.`); diff --git a/scripts/db-migrate.mjs b/scripts/db-migrate.mjs index cda4269..b9796a8 100644 --- a/scripts/db-migrate.mjs +++ b/scripts/db-migrate.mjs @@ -5,128 +5,217 @@ import process from 'node:process'; import { randomUUID } from 'node:crypto'; import { initializeDefaultUsers } from './default-users.mjs'; -const gearSlots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask']; -const isRecord = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value); +const gearSlots = [ + 'Holster', + 'Gloves', + 'Backpack', + 'Knee Pads', + 'Body Armor', + 'Mask', +]; +const isRecord = (value) => + Boolean(value) && typeof value === 'object' && !Array.isArray(value); const hasExactKeys = (value, keys) => - isRecord(value) && Object.keys(value).length === keys.length && keys.every((key) => key in value); -const isFiniteNumber = (value) => typeof value === 'number' && Number.isFinite(value); -const displayNameKey = (displayName) => String(displayName ?? '').normalize('NFKC').toLocaleLowerCase('en-US'); -const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + isRecord(value) && + Object.keys(value).length === keys.length && + keys.every((key) => key in value); +const isFiniteNumber = (value) => + typeof value === 'number' && Number.isFinite(value); +const displayNameKey = (displayName) => + String(displayName ?? '') + .normalize('NFKC') + .toLocaleLowerCase('en-US'); +const uuidPattern = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const publicProfileSlugBase = (displayName, userId) => { - const normalized = String(displayName ?? '') - .normalize('NFKC') - .toLocaleLowerCase('en-US') - .replace(/[^\p{L}\p{N}\p{M}]+/gu, '-') - .replace(/^-+|-+$/g, '') - .slice(0, 64) - .replace(/-+$/g, ''); - const fallback = `agent-${String(userId).replaceAll('-', '').slice(0, 8).toLowerCase()}`; - if (!/[\p{L}\p{N}]/u.test(normalized)) return fallback; - return uuidPattern.test(normalized) ? `agent-${normalized}` : normalized; + const normalized = String(displayName ?? '') + .normalize('NFKC') + .toLocaleLowerCase('en-US') + .replace(/[^\p{L}\p{N}\p{M}]+/gu, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 64) + .replace(/-+$/g, ''); + const fallback = `agent-${String(userId).replaceAll('-', '').slice(0, 8).toLowerCase()}`; + if (!/[\p{L}\p{N}]/u.test(normalized)) return fallback; + return uuidPattern.test(normalized) ? `agent-${normalized}` : normalized; }; const isRoll = (value) => - hasExactKeys(value, ['statId', 'value']) && typeof value.statId === 'string' && isFiniteNumber(value.value); -const isRollPair = (value) => Array.isArray(value) && value.length === 2 && value.every(isRoll); + hasExactKeys(value, ['statId', 'value']) && + typeof value.statId === 'string' && + isFiniteNumber(value.value); +const isRollPair = (value) => + Array.isArray(value) && value.length === 2 && value.every(isRoll); const isGearSelection = (value) => - hasExactKeys(value, ['itemId', 'level', 'brandId', 'talentId', 'health', 'armor', 'firepower', 'toughness', 'engineering', 'rolls', 'augmentSlots']) - && ['itemId', 'brandId', 'talentId'].every((key) => typeof value[key] === 'string') - && ['level', 'health', 'armor', 'firepower', 'toughness', 'engineering'].every((key) => isFiniteNumber(value[key])) - && isRollPair(value.rolls) - && Array.isArray(value.augmentSlots) - && value.augmentSlots.every(isFiniteNumber); + hasExactKeys(value, [ + 'itemId', + 'level', + 'brandId', + 'talentId', + 'health', + 'armor', + 'firepower', + 'toughness', + 'engineering', + 'rolls', + 'augmentSlots', + ]) && + ['itemId', 'brandId', 'talentId'].every( + (key) => typeof value[key] === 'string', + ) && + ['level', 'health', 'armor', 'firepower', 'toughness', 'engineering'].every( + (key) => isFiniteNumber(value[key]), + ) && + isRollPair(value.rolls) && + Array.isArray(value.augmentSlots) && + value.augmentSlots.every(isFiniteNumber); const isWeaponSelection = (value) => - hasExactKeys(value, ['itemId', 'level', 'tier', 'damage', 'firepower', 'toughness', 'engineering', 'talent1Id', 'talent2Id', 'rolls']) - && ['itemId', 'talent1Id', 'talent2Id'].every((key) => typeof value[key] === 'string') - && ['level', 'tier', 'damage', 'firepower', 'toughness', 'engineering'].every((key) => isFiniteNumber(value[key])) - && isRollPair(value.rolls); + hasExactKeys(value, [ + 'itemId', + 'level', + 'tier', + 'damage', + 'firepower', + 'toughness', + 'engineering', + 'talent1Id', + 'talent2Id', + 'rolls', + ]) && + ['itemId', 'talent1Id', 'talent2Id'].every( + (key) => typeof value[key] === 'string', + ) && + ['level', 'tier', 'damage', 'firepower', 'toughness', 'engineering'].every( + (key) => isFiniteNumber(value[key]), + ) && + isRollPair(value.rolls); const isChipSelection = (value) => - hasExactKeys(value, ['setId', 'quality', 'primary', 'secondary']) - && typeof value.setId === 'string' - && (value.quality === 'Superior' || value.quality === 'High-end') - && isRoll(value.primary) - && isRollPair(value.secondary); + hasExactKeys(value, ['setId', 'quality', 'primary', 'secondary']) && + typeof value.setId === 'string' && + (value.quality === 'Superior' || value.quality === 'High-end') && + isRoll(value.primary) && + isRollPair(value.secondary); const isProtocolSelection = (value) => - hasExactKeys(value, ['itemId', 'level', 'talentUpgrades']) - && typeof value.itemId === 'string' - && isFiniteNumber(value.level) - && isFiniteNumber(value.talentUpgrades); + hasExactKeys(value, ['itemId', 'level', 'talentUpgrades']) && + typeof value.itemId === 'string' && + isFiniteNumber(value.level) && + isFiniteNumber(value.talentUpgrades); const numericCombatProfileKeys = [ - 'encounterSeconds', 'hitRate', 'headshotRate', 'shotsPerBurst', 'burstPauseSeconds', - 'sameTargetSeconds', 'distinctTargetsPerMinute', 'killsPerMinute', 'headshotKillRate', 'damageTakenPerMinute', - 'swapsPerMinute', 'targetDistanceMeters', 'healthPercent' + 'encounterSeconds', + 'hitRate', + 'headshotRate', + 'shotsPerBurst', + 'burstPauseSeconds', + 'sameTargetSeconds', + 'distinctTargetsPerMinute', + 'killsPerMinute', + 'headshotKillRate', + 'damageTakenPerMinute', + 'swapsPerMinute', + 'targetDistanceMeters', + 'healthPercent', ]; const combatProfileKeys = [...numericCombatProfileKeys, 'extraHealthActive']; const isCombatProfile = (value) => - hasExactKeys(value, combatProfileKeys) - && numericCombatProfileKeys.every((key) => isFiniteNumber(value[key])) - && typeof value.extraHealthActive === 'boolean'; + hasExactKeys(value, combatProfileKeys) && + numericCombatProfileKeys.every((key) => isFiniteNumber(value[key])) && + typeof value.extraHealthActive === 'boolean'; const isEffectStates = (value) => - isRecord(value) && Object.values(value).every((entry) => { - if (!isRecord(entry) - || Object.keys(entry).some((key) => !['mode', 'overrides', 'inputs'].includes(key)) - || !['off', 'peak', 'encounter'].includes(String(entry.mode))) return false; - if (entry.inputs !== undefined && ( - !isRecord(entry.inputs) - || Object.values(entry.inputs).some((input) => - typeof input !== 'boolean' && !isFiniteNumber(input) - ) - )) return false; - return entry.overrides === undefined || ( - isRecord(entry.overrides) - && Object.entries(entry.overrides).every(([key, override]) => - key === 'extraHealthActive' - ? typeof override === 'boolean' - : numericCombatProfileKeys.includes(key) && isFiniteNumber(override) - ) - ); - }); + isRecord(value) && + Object.values(value).every((entry) => { + if ( + !isRecord(entry) || + Object.keys(entry).some( + (key) => !['mode', 'overrides', 'inputs'].includes(key), + ) || + !['off', 'peak', 'encounter'].includes(String(entry.mode)) + ) + return false; + if ( + entry.inputs !== undefined && + (!isRecord(entry.inputs) || + Object.values(entry.inputs).some( + (input) => typeof input !== 'boolean' && !isFiniteNumber(input), + )) + ) + return false; + return ( + entry.overrides === undefined || + (isRecord(entry.overrides) && + Object.entries(entry.overrides).every(([key, override]) => + key === 'extraHealthActive' + ? typeof override === 'boolean' + : numericCombatProfileKeys.includes(key) && + isFiniteNumber(override), + )) + ); + }); const isSkillActivations = (value) => - isRecord(value) && Object.values(value).every((entry) => - isRecord(entry) - && Object.keys(entry).every((key) => ['mode', 'usesPerMinute', 'targets'].includes(key)) - && ['off', 'on_cooldown', 'custom_per_minute'].includes(String(entry.mode)) - && isFiniteNumber(entry.targets) - && (entry.usesPerMinute === undefined || isFiniteNumber(entry.usesPerMinute)) - ); + isRecord(value) && + Object.values(value).every( + (entry) => + isRecord(entry) && + Object.keys(entry).every((key) => + ['mode', 'usesPerMinute', 'targets'].includes(key), + ) && + ['off', 'on_cooldown', 'custom_per_minute'].includes( + String(entry.mode), + ) && + isFiniteNumber(entry.targets) && + (entry.usesPerMinute === undefined || + isFiniteNumber(entry.usesPerMinute)), + ); const isCurrentLoadoutSnapshot = (raw) => { - let value; - try { - value = typeof raw === 'string' ? JSON.parse(raw) : raw; - } catch { - return false; - } - return hasExactKeys(value, [ - 'version', 'buildName', 'gear', 'weapons', 'chips', 'protocol', - 'conditionalEffects', 'combatProfile', 'calculationMode', 'skillActivations', 'specialization' - ]) - && value.version === 4 - && typeof value.buildName === 'string' - && ['maximum', 'custom'].includes(value.calculationMode) - && hasExactKeys(value.gear, gearSlots) - && gearSlots.every((slot) => isGearSelection(value.gear[slot])) - && Array.isArray(value.weapons) - && value.weapons.length === 2 - && value.weapons.every(isWeaponSelection) - && Array.isArray(value.chips) - && value.chips.length === 9 - && value.chips.every(isChipSelection) - && isProtocolSelection(value.protocol) - && isEffectStates(value.conditionalEffects) - && isCombatProfile(value.combatProfile) - && isSkillActivations(value.skillActivations) - && hasExactKeys(value.specialization, ['focusId', 'nodeRanks']) - && typeof value.specialization.focusId === 'string' - && isRecord(value.specialization.nodeRanks) - && Object.values(value.specialization.nodeRanks).every(isFiniteNumber); + let value; + try { + value = typeof raw === 'string' ? JSON.parse(raw) : raw; + } catch { + return false; + } + return ( + hasExactKeys(value, [ + 'version', + 'buildName', + 'gear', + 'weapons', + 'chips', + 'protocol', + 'conditionalEffects', + 'combatProfile', + 'calculationMode', + 'skillActivations', + 'specialization', + ]) && + value.version === 4 && + typeof value.buildName === 'string' && + ['maximum', 'custom'].includes(value.calculationMode) && + hasExactKeys(value.gear, gearSlots) && + gearSlots.every((slot) => isGearSelection(value.gear[slot])) && + Array.isArray(value.weapons) && + value.weapons.length === 2 && + value.weapons.every(isWeaponSelection) && + Array.isArray(value.chips) && + value.chips.length === 9 && + value.chips.every(isChipSelection) && + isProtocolSelection(value.protocol) && + isEffectStates(value.conditionalEffects) && + isCombatProfile(value.combatProfile) && + isSkillActivations(value.skillActivations) && + hasExactKeys(value.specialization, ['focusId', 'nodeRanks']) && + typeof value.specialization.focusId === 'string' && + isRecord(value.specialization.nodeRanks) && + Object.values(value.specialization.nodeRanks).every(isFiniteNumber) + ); }; -const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build.db'); +const databasePath = resolve( + process.env.TDR_DATABASE_PATH || './data/shd-build.db', +); const schemaPath = resolve('./database/schema.sql'); let databaseAlreadyExisted = true; try { - await access(databasePath); + await access(databasePath); } catch { - databaseAlreadyExisted = false; + databaseAlreadyExisted = false; } await mkdir(dirname(databasePath), { recursive: true }); @@ -134,61 +223,90 @@ await mkdir(dirname(databasePath), { recursive: true }); const database = await connect(databasePath); const schema = await readFile(schemaPath, 'utf8'); -const usersTable = await (await database.prepare( - "SELECT 1 FROM sqlite_master WHERE type='table' AND name='users'" -)).get(); +const usersTable = await ( + await database.prepare( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='users'", + ) +).get(); if (usersTable) { - const userColumns = await (await database.prepare("PRAGMA table_info('users')")).all(); - if (!userColumns.some((column) => column.name === 'login_username')) { - console.log('Separating optional local login usernames from internal account identifiers.'); - await database.exec('ALTER TABLE users ADD COLUMN login_username TEXT'); - await database.exec('UPDATE users SET login_username=username WHERE password_hash IS NOT NULL'); - } + const userColumns = await ( + await database.prepare("PRAGMA table_info('users')") + ).all(); + if (!userColumns.some((column) => column.name === 'login_username')) { + console.log( + 'Separating optional local login usernames from internal account identifiers.', + ); + await database.exec('ALTER TABLE users ADD COLUMN login_username TEXT'); + await database.exec( + 'UPDATE users SET login_username=username WHERE password_hash IS NOT NULL', + ); + } } -const userProfilesTable = await (await database.prepare( - "SELECT 1 FROM sqlite_master WHERE type='table' AND name='user_profiles'" -)).get(); +const userProfilesTable = await ( + await database.prepare( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='user_profiles'", + ) +).get(); if (userProfilesTable) { - const profileColumns = await (await database.prepare("PRAGMA table_info('user_profiles')")).all(); - if (!profileColumns.some((column) => column.name === 'bio_markdown')) { - console.log('Adding Markdown biographies to public agent profiles.'); - await database.exec("ALTER TABLE user_profiles ADD COLUMN bio_markdown TEXT NOT NULL DEFAULT ''"); - } - if (!profileColumns.some((column) => column.name === 'is_public')) { - console.log('Adding public profile visibility controls.'); - await database.exec('ALTER TABLE user_profiles ADD COLUMN is_public INTEGER NOT NULL DEFAULT 1 CHECK(is_public IN (0, 1))'); - } - if (!profileColumns.some((column) => column.name === 'bio_updated_at')) { - await database.exec('ALTER TABLE user_profiles ADD COLUMN bio_updated_at TEXT'); - } - if (!profileColumns.some((column) => column.name === 'public_slug')) { - console.log('Adding readable public profile slugs.'); - await database.exec('ALTER TABLE user_profiles ADD COLUMN public_slug TEXT'); - } - if (!profileColumns.some((column) => column.name === 'display_name_key')) { - console.log('Adding normalized display-name identity keys.'); - await database.exec('ALTER TABLE user_profiles ADD COLUMN display_name_key TEXT'); - } + const profileColumns = await ( + await database.prepare("PRAGMA table_info('user_profiles')") + ).all(); + if (!profileColumns.some((column) => column.name === 'bio_markdown')) { + console.log('Adding Markdown biographies to public agent profiles.'); + await database.exec( + "ALTER TABLE user_profiles ADD COLUMN bio_markdown TEXT NOT NULL DEFAULT ''", + ); + } + if (!profileColumns.some((column) => column.name === 'is_public')) { + console.log('Adding public profile visibility controls.'); + await database.exec( + 'ALTER TABLE user_profiles ADD COLUMN is_public INTEGER NOT NULL DEFAULT 1 CHECK(is_public IN (0, 1))', + ); + } + if (!profileColumns.some((column) => column.name === 'bio_updated_at')) { + await database.exec( + 'ALTER TABLE user_profiles ADD COLUMN bio_updated_at TEXT', + ); + } + if (!profileColumns.some((column) => column.name === 'public_slug')) { + console.log('Adding readable public profile slugs.'); + await database.exec( + 'ALTER TABLE user_profiles ADD COLUMN public_slug TEXT', + ); + } + if (!profileColumns.some((column) => column.name === 'display_name_key')) { + console.log('Adding normalized display-name identity keys.'); + await database.exec( + 'ALTER TABLE user_profiles ADD COLUMN display_name_key TEXT', + ); + } } -const commentsDefinition = await (await database.prepare( - "SELECT sql FROM sqlite_master WHERE type='table' AND name='comments'" -)).get(); -if (typeof commentsDefinition?.sql === 'string' && commentsDefinition.sql.includes('build_id')) { - console.log('Normalizing build comments into reusable community comment threads.'); - await database.exec('PRAGMA foreign_keys=OFF'); - await database.exec('BEGIN'); - try { - await database.exec('ALTER TABLE comments RENAME TO comments_legacy'); - await database.exec(`CREATE TABLE comment_threads ( +const commentsDefinition = await ( + await database.prepare( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='comments'", + ) +).get(); +if ( + typeof commentsDefinition?.sql === 'string' && + commentsDefinition.sql.includes('build_id') +) { + console.log( + 'Normalizing build comments into reusable community comment threads.', + ); + await database.exec('PRAGMA foreign_keys=OFF'); + await database.exec('BEGIN'); + try { + await database.exec('ALTER TABLE comments RENAME TO comments_legacy'); + await database.exec(`CREATE TABLE comment_threads ( id TEXT PRIMARY KEY, target_type TEXT NOT NULL CHECK(target_type IN ('build', 'guide', 'profile')), target_id TEXT NOT NULL, created_at TEXT NOT NULL, UNIQUE(target_type, target_id) )`); - await database.exec(`CREATE TABLE comments ( + await database.exec(`CREATE TABLE comments ( id TEXT PRIMARY KEY, thread_id TEXT NOT NULL REFERENCES comment_threads(id) ON DELETE CASCADE, author_id TEXT NOT NULL REFERENCES users(id), @@ -201,37 +319,39 @@ if (typeof commentsDefinition?.sql === 'string' && commentsDefinition.sql.includ removed_by TEXT REFERENCES users(id), removal_reason TEXT )`); - await database.exec(`INSERT INTO comment_threads(id,target_type,target_id,created_at) + await database.exec(`INSERT INTO comment_threads(id,target_type,target_id,created_at) SELECT 'build:' || build_id,'build',build_id,MIN(created_at) FROM comments_legacy GROUP BY build_id`); - await database.exec(`INSERT INTO comments( + await database.exec(`INSERT INTO comments( id,thread_id,author_id,parent_id,body_markdown,status,created_at,updated_at ) SELECT id,'build:' || build_id,user_id,NULL,body,'visible',created_at,created_at FROM comments_legacy`); - await database.exec('DROP TABLE comments_legacy'); - await database.exec('COMMIT'); - } catch (error) { - await database.exec('ROLLBACK'); - throw error; - } finally { - await database.exec('PRAGMA foreign_keys=ON'); - } + await database.exec('DROP TABLE comments_legacy'); + await database.exec('COMMIT'); + } catch (error) { + await database.exec('ROLLBACK'); + throw error; + } finally { + await database.exec('PRAGMA foreign_keys=ON'); + } } -const normalizedCommentsDefinition = await (await database.prepare( - "SELECT sql FROM sqlite_master WHERE type='table' AND name='comments'" -)).get(); +const normalizedCommentsDefinition = await ( + await database.prepare( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='comments'", + ) +).get(); if ( - typeof normalizedCommentsDefinition?.sql === 'string' - && !normalizedCommentsDefinition.sql.includes("'pending_review'") + typeof normalizedCommentsDefinition?.sql === 'string' && + !normalizedCommentsDefinition.sql.includes("'pending_review'") ) { - console.log('Adding pre-publication review status to community comments.'); - await database.exec('PRAGMA foreign_keys=OFF'); - await database.exec('BEGIN'); - try { - await database.exec('ALTER TABLE comments RENAME TO comments_previous'); - await database.exec(`CREATE TABLE comments ( + console.log('Adding pre-publication review status to community comments.'); + await database.exec('PRAGMA foreign_keys=OFF'); + await database.exec('BEGIN'); + try { + await database.exec('ALTER TABLE comments RENAME TO comments_previous'); + await database.exec(`CREATE TABLE comments ( id TEXT PRIMARY KEY, thread_id TEXT NOT NULL REFERENCES comment_threads(id) ON DELETE CASCADE, author_id TEXT NOT NULL REFERENCES users(id), @@ -244,56 +364,100 @@ if ( removed_by TEXT REFERENCES users(id), removal_reason TEXT )`); - await database.exec(`INSERT INTO comments( + await database.exec(`INSERT INTO comments( id,thread_id,author_id,parent_id,body_markdown,status,created_at,updated_at, removed_at,removed_by,removal_reason ) SELECT id,thread_id,author_id,parent_id,body_markdown,status,created_at,updated_at, removed_at,removed_by,removal_reason FROM comments_previous`); - await database.exec('DROP TABLE comments_previous'); - await database.exec('COMMIT'); - } catch (error) { - await database.exec('ROLLBACK'); - throw error; - } finally { - await database.exec('PRAGMA foreign_keys=ON'); - } + await database.exec('DROP TABLE comments_previous'); + await database.exec('COMMIT'); + } catch (error) { + await database.exec('ROLLBACK'); + throw error; + } finally { + await database.exec('PRAGMA foreign_keys=ON'); + } } -const moderationScansDefinition = await (await database.prepare( - "SELECT sql FROM sqlite_master WHERE type='table' AND name='content_moderation_scans'" -)).get(); -const moderationScansMissingEvidence = ( - typeof moderationScansDefinition?.sql === 'string' - && ( - !moderationScansDefinition.sql.includes('author_id TEXT') - || !moderationScansDefinition.sql.includes('risk_level TEXT') - || !moderationScansDefinition.sql.includes('reason TEXT') - ) -); +const commentThreadsDefinition = await ( + await database.prepare( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='comment_threads'", + ) +).get(); if ( - typeof moderationScansDefinition?.sql === 'string' - && ( - !moderationScansDefinition.sql.includes("'avatar'") - || moderationScansMissingEvidence - ) + typeof commentThreadsDefinition?.sql === 'string' && + (!commentThreadsDefinition.sql.includes("'game_data'") || + !commentThreadsDefinition.sql.includes("'news'") || + !commentThreadsDefinition.sql.includes('pinned_comment_id')) ) { - if (moderationScansMissingEvidence) { - const existingScans = await ( - await database.prepare('SELECT COUNT(*) AS count FROM content_moderation_scans') - ).get(); - if (Number(existingScans?.count) > 0) { - throw new Error( - 'Existing moderation scans lack required author, risk, or reason evidence and cannot be backfilled safely.' - ); - } - } - console.log('Aligning automated community moderation scan evidence.'); - await database.exec('PRAGMA foreign_keys=OFF'); - await database.exec('BEGIN'); - try { - await database.exec(`CREATE TABLE content_moderation_scans_expanded ( + console.log( + 'Adding game-data and News discussion targets with pinned community notes.', + ); + await database.exec('PRAGMA foreign_keys=OFF'); + await database.exec('BEGIN'); + try { + await database.exec(`CREATE TABLE comment_threads_expanded ( + id TEXT PRIMARY KEY, + target_type TEXT NOT NULL CHECK(target_type IN ('build', 'guide', 'news', 'profile', 'game_data')), + target_id TEXT NOT NULL, + pinned_comment_id TEXT REFERENCES comments(id) ON DELETE SET NULL, + created_at TEXT NOT NULL, + UNIQUE(target_type, target_id) + )`); + const pinnedCommentProjection = commentThreadsDefinition.sql.includes( + 'pinned_comment_id', + ) + ? 'pinned_comment_id' + : 'NULL'; + await database.exec(`INSERT INTO comment_threads_expanded(id,target_type,target_id,pinned_comment_id,created_at) + SELECT id,target_type,target_id,${pinnedCommentProjection},created_at FROM comment_threads`); + await database.exec('DROP TABLE comment_threads'); + await database.exec( + 'ALTER TABLE comment_threads_expanded RENAME TO comment_threads', + ); + await database.exec('COMMIT'); + } catch (error) { + await database.exec('ROLLBACK'); + throw error; + } finally { + await database.exec('PRAGMA foreign_keys=ON'); + } +} + +const moderationScansDefinition = await ( + await database.prepare( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='content_moderation_scans'", + ) +).get(); +const moderationScansMissingEvidence = + typeof moderationScansDefinition?.sql === 'string' && + (!moderationScansDefinition.sql.includes('author_id TEXT') || + !moderationScansDefinition.sql.includes('risk_level TEXT') || + !moderationScansDefinition.sql.includes('reason TEXT')); +if ( + typeof moderationScansDefinition?.sql === 'string' && + (!moderationScansDefinition.sql.includes("'avatar'") || + moderationScansMissingEvidence) +) { + if (moderationScansMissingEvidence) { + const existingScans = await ( + await database.prepare( + 'SELECT COUNT(*) AS count FROM content_moderation_scans', + ) + ).get(); + if (Number(existingScans?.count) > 0) { + throw new Error( + 'Existing moderation scans lack required author, risk, or reason evidence and cannot be backfilled safely.', + ); + } + } + console.log('Aligning automated community moderation scan evidence.'); + await database.exec('PRAGMA foreign_keys=OFF'); + await database.exec('BEGIN'); + try { + await database.exec(`CREATE TABLE content_moderation_scans_expanded ( id TEXT PRIMARY KEY, content_type TEXT NOT NULL CHECK(content_type IN ('comment','profile_bio','guide','avatar')), content_id TEXT NOT NULL, @@ -324,8 +488,8 @@ if ( OR (status<>'error' AND error_code IS NULL AND error_message IS NULL) ) )`); - if (!moderationScansMissingEvidence) { - await database.exec(`INSERT INTO content_moderation_scans_expanded( + if (!moderationScansMissingEvidence) { + await database.exec(`INSERT INTO content_moderation_scans_expanded( id,content_type,content_id,author_id,content_hash,revision_id,policy_version,provider,model, provider_request_id,status,risk_level,reason,scores_json,flags_json,rule_matches_json, created_at,completed_at,error_code,error_message @@ -334,168 +498,486 @@ if ( provider_request_id,status,risk_level,reason,scores_json,flags_json,rule_matches_json, created_at,completed_at,error_code,error_message FROM content_moderation_scans`); - } - await database.exec('DROP TABLE content_moderation_scans'); - await database.exec('ALTER TABLE content_moderation_scans_expanded RENAME TO content_moderation_scans'); - await database.exec('COMMIT'); - } catch (error) { - await database.exec('ROLLBACK'); - throw error; - } finally { - await database.exec('PRAGMA foreign_keys=ON'); - } + } + await database.exec('DROP TABLE content_moderation_scans'); + await database.exec( + 'ALTER TABLE content_moderation_scans_expanded RENAME TO content_moderation_scans', + ); + await database.exec('COMMIT'); + } catch (error) { + await database.exec('ROLLBACK'); + throw error; + } finally { + await database.exec('PRAGMA foreign_keys=ON'); + } } const communityModerationSettingsColumns = await ( - await database.prepare("PRAGMA table_info('community_moderation_settings')") + await database.prepare("PRAGMA table_info('community_moderation_settings')") ).all(); if ( - communityModerationSettingsColumns.length - && !communityModerationSettingsColumns.some((column) => column.name === 'scan_avatars') + communityModerationSettingsColumns.length && + !communityModerationSettingsColumns.some( + (column) => column.name === 'scan_avatars', + ) ) { - console.log('Adding avatar scanning to community moderation settings.'); - await database.exec(`ALTER TABLE community_moderation_settings + console.log('Adding avatar scanning to community moderation settings.'); + await database.exec(`ALTER TABLE community_moderation_settings ADD COLUMN scan_avatars INTEGER NOT NULL DEFAULT 1 CHECK(scan_avatars IN (0,1))`); } const avatarReviewColumns = await ( - await database.prepare("PRAGMA table_info('avatar_reviews')") + await database.prepare("PRAGMA table_info('avatar_reviews')") ).all(); if ( - avatarReviewColumns.length - && !avatarReviewColumns.some((column) => column.name === 'review_origin') + avatarReviewColumns.length && + !avatarReviewColumns.some((column) => column.name === 'review_origin') ) { - console.log('Adding review origin evidence to avatar oversight.'); - await database.exec(`ALTER TABLE avatar_reviews + console.log('Adding review origin evidence to avatar oversight.'); + await database.exec(`ALTER TABLE avatar_reviews ADD COLUMN review_origin TEXT NOT NULL DEFAULT 'legacy' CHECK(review_origin IN ('automated','user_report','legacy'))`); } -// The project schema deliberately avoids semicolons inside string literals. -const statements = schema - .split(';') - .map((statement) => statement.trim()) - .filter(Boolean); - -for (const statement of statements) { - // This index is deferred until the additive legacy-column migration below. - if (statement.startsWith('CREATE INDEX IF NOT EXISTS skill_chip_sets_ability_idx')) continue; - if (statement.startsWith('CREATE UNIQUE INDEX IF NOT EXISTS managed_images_source_entity_idx')) continue; - if (statement.startsWith('CREATE INDEX IF NOT EXISTS conditional_effect_clauses_event_idx')) continue; - if (statement.startsWith('CREATE UNIQUE INDEX IF NOT EXISTS user_profiles_public_slug_idx')) continue; - if (statement.startsWith('CREATE UNIQUE INDEX IF NOT EXISTS user_profiles_display_name_key_idx')) continue; - await database.exec(statement); +const contributorProposalColumns = await ( + await database.prepare("PRAGMA table_info('data_change_proposals')") +).all(); +if ( + contributorProposalColumns.length && + !contributorProposalColumns.some( + (column) => column.name === 'contributor_note', + ) +) { + console.log( + 'Adding optional contributor rationale to data change proposals.', + ); + await database.exec( + 'ALTER TABLE data_change_proposals ADD COLUMN contributor_note TEXT', + ); } +const legacyBuildColumns = await ( + await database.prepare("PRAGMA table_info('builds')") +).all(); +if ( + legacyBuildColumns.some( + (column) => column.name === 'summary' || column.name === 'guide_body', + ) +) { + console.log( + 'Removing the retired Build Notes prototype without migrating its development content.', + ); + await database.exec('PRAGMA foreign_keys=OFF'); + await database.exec('BEGIN'); + try { + await database.exec(`DELETE FROM comments WHERE thread_id IN ( + SELECT id FROM comment_threads WHERE target_type='guide' + )`); + await database.exec( + "DELETE FROM comment_threads WHERE target_type='guide'", + ); + await database.exec(`DELETE FROM content_moderation_reviews WHERE scan_id IN ( + SELECT id FROM content_moderation_scans WHERE content_type='guide' + )`); + await database.exec( + "DELETE FROM content_moderation_scans WHERE content_type='guide'", + ); + await database.exec( + "DELETE FROM moderated_content_revisions WHERE content_type='guide'", + ); + await database.exec( + "DELETE FROM user_content_reports WHERE content_type='guide'", + ); + await database.exec('DROP TABLE IF EXISTS builds_without_notes'); + await database.exec(`CREATE TABLE builds_without_notes ( + id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL REFERENCES users(id), + title TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + visibility TEXT NOT NULL DEFAULT 'private' CHECK(visibility IN ('private','unlisted','public')), + source_build_id TEXT REFERENCES builds_without_notes(id), + loadout_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )`); + await database.exec(`INSERT INTO builds_without_notes( + id,owner_id,title,slug,visibility,source_build_id,loadout_json,created_at,updated_at + ) SELECT id,owner_id,title,slug, + CASE WHEN visibility IN ('private','unlisted','public') THEN visibility ELSE 'private' END, + source_build_id,loadout_json,created_at,updated_at FROM builds`); + await database.exec('DROP TABLE builds'); + await database.exec('ALTER TABLE builds_without_notes RENAME TO builds'); + await database.exec('COMMIT'); + } catch (error) { + await database.exec('ROLLBACK'); + throw error; + } finally { + await database.exec('PRAGMA foreign_keys=ON'); + } +} + +// The project schema deliberately avoids semicolons inside string literals. +const statements = schema + .split(';') + .map((statement) => statement.trim()) + .filter(Boolean); + +for (const statement of statements) { + // This index is deferred until the additive legacy-column migration below. + if ( + statement.startsWith( + 'CREATE INDEX IF NOT EXISTS skill_chip_sets_ability_idx', + ) + ) + continue; + if ( + statement.startsWith( + 'CREATE UNIQUE INDEX IF NOT EXISTS managed_images_source_entity_idx', + ) + ) + continue; + if ( + statement.startsWith( + 'CREATE INDEX IF NOT EXISTS conditional_effect_clauses_event_idx', + ) + ) + continue; + if ( + statement.startsWith( + 'CREATE UNIQUE INDEX IF NOT EXISTS user_profiles_public_slug_idx', + ) + ) + continue; + if ( + statement.startsWith( + 'CREATE UNIQUE INDEX IF NOT EXISTS user_profiles_display_name_key_idx', + ) + ) + continue; + if ( + statement.startsWith( + 'CREATE UNIQUE INDEX IF NOT EXISTS content_moderation_scans_guide_unique_idx', + ) + ) + continue; + if ( + statement.startsWith( + 'CREATE UNIQUE INDEX IF NOT EXISTS user_content_reports_one_anonymous_pending_idx', + ) + ) + continue; + await database.exec(statement); +} + +const articleRevisionColumns = await ( + await database.prepare("PRAGMA table_info('article_revisions')") +).all(); +if (articleRevisionColumns.some((column) => column.name === 'plaint_text')) { + console.log('Correcting the article revision plain-text column name.'); + await database.exec( + 'ALTER TABLE article_revisions RENAME COLUMN plaint_text TO plain_text', + ); +} + +const currentModerationScanColumns = await ( + await database.prepare("PRAGMA table_info('content_moderation_scans')") +).all(); +if ( + currentModerationScanColumns.length && + !currentModerationScanColumns.some( + (column) => column.name === 'article_revision_id', + ) +) { + console.log( + 'Binding Guide moderation evidence to immutable article revisions.', + ); + await database.exec('PRAGMA foreign_keys=OFF'); + await database.exec('BEGIN'); + try { + await database.exec(`CREATE TABLE content_moderation_scans_article ( + id TEXT PRIMARY KEY, + content_type TEXT NOT NULL CHECK(content_type IN ('comment','profile_bio','guide','avatar')), + content_id TEXT NOT NULL, + author_id TEXT NOT NULL REFERENCES users(id), + content_hash TEXT NOT NULL CHECK(length(content_hash)>0), + revision_id TEXT REFERENCES moderated_content_revisions(id), + article_revision_id TEXT REFERENCES article_revisions(id), + policy_version INTEGER NOT NULL CHECK(policy_version>=1), + provider TEXT NOT NULL CHECK(length(trim(provider))>0), + model TEXT NOT NULL CHECK(length(trim(model))>0), + provider_request_id TEXT, + status TEXT NOT NULL CHECK(status IN ('approved','review','priority_review','error')), + risk_level TEXT NOT NULL CHECK(risk_level IN ('low','medium','high')), + reason TEXT NOT NULL CHECK(length(trim(reason))>0), + scores_json TEXT NOT NULL DEFAULT '{}', + flags_json TEXT NOT NULL DEFAULT '[]', + rule_matches_json TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL, + completed_at TEXT, + error_code TEXT, + error_message TEXT, + CHECK( + (content_type IN ('comment','avatar') AND revision_id IS NULL AND article_revision_id IS NULL) + OR (content_type='profile_bio' AND revision_id IS NOT NULL AND article_revision_id IS NULL) + OR (content_type='guide' AND revision_id IS NULL AND article_revision_id IS NOT NULL) + ), + CHECK( + (status='error' AND error_code IS NOT NULL) + OR (status<>'error' AND error_code IS NULL AND error_message IS NULL) + ) + )`); + await database.exec(`INSERT INTO content_moderation_scans_article( + id,content_type,content_id,author_id,content_hash,revision_id,article_revision_id, + policy_version,provider,model,provider_request_id,status,risk_level,reason, + scores_json,flags_json,rule_matches_json,created_at,completed_at,error_code,error_message + ) SELECT id,content_type,content_id,author_id,content_hash,revision_id,NULL, + policy_version,provider,model,provider_request_id,status,risk_level,reason, + scores_json,flags_json,rule_matches_json,created_at,completed_at,error_code,error_message + FROM content_moderation_scans WHERE content_type<>'guide'`); + await database.exec('DROP TABLE content_moderation_scans'); + await database.exec( + 'ALTER TABLE content_moderation_scans_article RENAME TO content_moderation_scans', + ); + await database.exec('COMMIT'); + } catch (error) { + await database.exec('ROLLBACK'); + throw error; + } finally { + await database.exec('PRAGMA foreign_keys=ON'); + } + await database.exec(`CREATE INDEX IF NOT EXISTS content_moderation_scans_pending_queue_idx + ON content_moderation_scans(status,created_at) WHERE status IN ('review','priority_review')`); + await database.exec(`CREATE INDEX IF NOT EXISTS content_moderation_scans_content_idx + ON content_moderation_scans(content_type,content_id,created_at DESC)`); + await database.exec(`CREATE UNIQUE INDEX IF NOT EXISTS content_moderation_scans_direct_unique_idx + ON content_moderation_scans(content_type,content_id,content_hash,policy_version,provider,model) + WHERE content_type IN ('comment','avatar')`); + await database.exec(`CREATE UNIQUE INDEX IF NOT EXISTS content_moderation_scans_profile_unique_idx + ON content_moderation_scans(revision_id,policy_version,provider,model) WHERE content_type='profile_bio'`); + await database.exec(`CREATE UNIQUE INDEX IF NOT EXISTS content_moderation_scans_guide_unique_idx + ON content_moderation_scans(article_revision_id,policy_version,provider,model) WHERE content_type='guide'`); +} + +const reportColumns = await ( + await database.prepare("PRAGMA table_info('user_content_reports')") +).all(); +if ( + reportColumns.length && + !reportColumns.some((column) => column.name === 'anonymous_token_hash') +) { + console.log('Adding revision-bound anonymous Guide reports.'); + await database.exec('PRAGMA foreign_keys=OFF'); + await database.exec('BEGIN'); + try { + await database.exec(`CREATE TABLE user_content_reports_article ( + id TEXT PRIMARY KEY, + content_type TEXT NOT NULL CHECK(content_type IN ('comment','profile_bio','guide','avatar')), + content_id TEXT NOT NULL, + reporter_id TEXT REFERENCES users(id), + anonymous_token_hash TEXT REFERENCES anonymous_visitors(token_hash), + article_revision_id TEXT REFERENCES article_revisions(id), + category TEXT NOT NULL CHECK(category IN ('spam','harassment','hate','sexual','violence','self_harm','privacy','other')), + details TEXT, + status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','resolved','dismissed')), + created_at TEXT NOT NULL, + reviewed_at TEXT, + reviewed_by TEXT REFERENCES users(id), + resolution_reason TEXT, + CHECK((reporter_id IS NOT NULL AND anonymous_token_hash IS NULL) OR (reporter_id IS NULL AND anonymous_token_hash IS NOT NULL)), + CHECK((content_type='guide' AND article_revision_id IS NOT NULL) OR (content_type<>'guide' AND article_revision_id IS NULL)), + CHECK( + (status='pending' AND reviewed_at IS NULL AND reviewed_by IS NULL AND resolution_reason IS NULL) + OR (status IN ('resolved','dismissed') AND reviewed_at IS NOT NULL AND reviewed_by IS NOT NULL + AND resolution_reason IS NOT NULL AND length(trim(resolution_reason))>0) + ) + )`); + await database.exec(`INSERT INTO user_content_reports_article( + id,content_type,content_id,reporter_id,anonymous_token_hash,article_revision_id, + category,details,status,created_at,reviewed_at,reviewed_by,resolution_reason + ) SELECT id,content_type,content_id,reporter_id,NULL,NULL,category,details,status,created_at, + reviewed_at,reviewed_by,resolution_reason FROM user_content_reports WHERE content_type<>'guide'`); + await database.exec('DROP TABLE user_content_reports'); + await database.exec( + 'ALTER TABLE user_content_reports_article RENAME TO user_content_reports', + ); + await database.exec('COMMIT'); + } catch (error) { + await database.exec('ROLLBACK'); + throw error; + } finally { + await database.exec('PRAGMA foreign_keys=ON'); + } + await database.exec( + "CREATE INDEX IF NOT EXISTS user_content_reports_pending_queue_idx ON user_content_reports(created_at) WHERE status='pending'", + ); + await database.exec( + 'CREATE INDEX IF NOT EXISTS user_content_reports_content_idx ON user_content_reports(content_type,content_id,created_at DESC)', + ); + await database.exec( + "CREATE UNIQUE INDEX IF NOT EXISTS user_content_reports_one_pending_idx ON user_content_reports(reporter_id,content_type,content_id) WHERE status='pending' AND reporter_id IS NOT NULL", + ); + await database.exec( + "CREATE UNIQUE INDEX IF NOT EXISTS user_content_reports_one_anonymous_pending_idx ON user_content_reports(anonymous_token_hash,content_type,content_id) WHERE status='pending' AND anonymous_token_hash IS NOT NULL", + ); +} + +await database.exec( + 'CREATE UNIQUE INDEX IF NOT EXISTS content_moderation_reviews_scan_unique_idx ON content_moderation_reviews(scan_id)', +); + const weaponBonusAttributes = await ( - await database.prepare(`SELECT id,name FROM stat_attributes + await database.prepare(`SELECT id,name FROM stat_attributes WHERE name IN ('Weapon Critical Hit Chance','Weapon Critical Hit Damage','Headshot Damage')`) ).all(); if (weaponBonusAttributes.length && weaponBonusAttributes.length !== 3) { - throw new Error('The shared weapon-category bonus migration requires all three fixed weapon Attributes.'); + throw new Error( + 'The shared weapon-category bonus migration requires all three fixed weapon Attributes.', + ); } if (weaponBonusAttributes.length === 3) { - const attributeId = (name) => String(weaponBonusAttributes.find((attribute) => attribute.name === name)?.id ?? ''); - const defaults = [ - ['Weapon Critical Hit Chance', 5], - ['Weapon Critical Hit Damage', 50], - ['Headshot Damage', 40] - ]; - await database.exec('BEGIN'); - try { - for (const category of ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG']) { - for (let index = 0; index < defaults.length; index += 1) { - const [name, defaultValue] = defaults[index]; - const value = category === 'MMR' && name === 'Headshot Damage' ? 70 : defaultValue; - await (await database.prepare(`INSERT OR IGNORE INTO weapon_category_attribute_bonuses( + const attributeId = (name) => + String( + weaponBonusAttributes.find((attribute) => attribute.name === name)?.id ?? + '', + ); + const defaults = [ + ['Weapon Critical Hit Chance', 5], + ['Weapon Critical Hit Damage', 50], + ['Headshot Damage', 40], + ]; + await database.exec('BEGIN'); + try { + for (const category of ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG']) { + for (let index = 0; index < defaults.length; index += 1) { + const [name, defaultValue] = defaults[index]; + const value = + category === 'MMR' && name === 'Headshot Damage' ? 70 : defaultValue; + await ( + await database.prepare(`INSERT OR IGNORE INTO weapon_category_attribute_bonuses( id,weapon_category,stat_attribute_id,value,sort_order,updated_at - ) VALUES(?,?,?,?,?,?)`)).run( - randomUUID(), category, attributeId(name), value, index + 1, new Date().toISOString() - ); - } - } - const configured = await (await database.prepare( - 'SELECT COUNT(*) AS count FROM weapon_category_attribute_bonuses' - )).get(); - if (Number(configured?.count) !== 18) { - throw new Error('Every weapon category must have exactly three shared base Attribute bonuses.'); - } - const legacyWeaponBonuses = await (await database.prepare( - "SELECT 1 FROM sqlite_master WHERE type='table' AND name='weapon_base_attribute_bonuses'" - )).get(); - if (legacyWeaponBonuses) { - console.log('Replacing per-weapon base Attribute bonuses with shared weapon-category profiles.'); - await database.exec('DROP TABLE weapon_base_attribute_bonuses'); - } - await database.exec('COMMIT'); - } catch (error) { - await database.exec('ROLLBACK'); - throw error; - } + ) VALUES(?,?,?,?,?,?)`) + ).run( + randomUUID(), + category, + attributeId(name), + value, + index + 1, + new Date().toISOString(), + ); + } + } + const configured = await ( + await database.prepare( + 'SELECT COUNT(*) AS count FROM weapon_category_attribute_bonuses', + ) + ).get(); + if (Number(configured?.count) !== 18) { + throw new Error( + 'Every weapon category must have exactly three shared base Attribute bonuses.', + ); + } + const legacyWeaponBonuses = await ( + await database.prepare( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='weapon_base_attribute_bonuses'", + ) + ).get(); + if (legacyWeaponBonuses) { + console.log( + 'Replacing per-weapon base Attribute bonuses with shared weapon-category profiles.', + ); + await database.exec('DROP TABLE weapon_base_attribute_bonuses'); + } + await database.exec('COMMIT'); + } catch (error) { + await database.exec('ROLLBACK'); + throw error; + } } -const deletedRoleTable = await (await database.prepare( - "SELECT 1 FROM sqlite_master WHERE type='table' AND name='deleted_roles'" -)).get(); +const deletedRoleTable = await ( + await database.prepare( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='deleted_roles'", + ) +).get(); if (deletedRoleTable) { - const deletedRoleRows = await (await database.prepare('SELECT role_id FROM deleted_roles')).all(); - for (const deletedRole of deletedRoleRows) { - const roleId = String(deletedRole.role_id); - await (await database.prepare('DELETE FROM user_roles WHERE role_id=?')).run(roleId); - await (await database.prepare('DELETE FROM role_permissions WHERE role_id=?')).run(roleId); - await (await database.prepare('DELETE FROM roles WHERE id=?')).run(roleId); - } + const deletedRoleRows = await ( + await database.prepare('SELECT role_id FROM deleted_roles') + ).all(); + for (const deletedRole of deletedRoleRows) { + const roleId = String(deletedRole.role_id); + await ( + await database.prepare('DELETE FROM user_roles WHERE role_id=?') + ).run(roleId); + await ( + await database.prepare('DELETE FROM role_permissions WHERE role_id=?') + ).run(roleId); + await (await database.prepare('DELETE FROM roles WHERE id=?')).run(roleId); + } } -const profilesForIdentityMigration = await (await database.prepare(`SELECT user_id,display_name,display_name_key,public_slug - FROM user_profiles ORDER BY user_id`)).all(); +const profilesForIdentityMigration = await ( + await database.prepare(`SELECT user_id,display_name,display_name_key,public_slug + FROM user_profiles ORDER BY user_id`) +).all(); const claimedProfileSlugs = new Set( - profilesForIdentityMigration - .filter((profile) => profile.public_slug) - .map((profile) => String(profile.public_slug)) + profilesForIdentityMigration + .filter((profile) => profile.public_slug) + .map((profile) => String(profile.public_slug)), ); const claimedDisplayNameKeys = new Set(); for (const profile of profilesForIdentityMigration) { - const userId = String(profile.user_id); - let displayName = String(profile.display_name); - let key = displayNameKey(displayName); - if (claimedDisplayNameKeys.has(key)) { - for (let suffixLength = 5; suffixLength <= 12; suffixLength += 1) { - const candidate = `${displayName} ${userId.replaceAll('-', '').slice(0, suffixLength).toLowerCase()}`; - const candidateKey = displayNameKey(candidate); - if (!claimedDisplayNameKeys.has(candidateKey)) { - displayName = candidate; - key = candidateKey; - break; - } - } - } - claimedDisplayNameKeys.add(key); - let slug = profile.public_slug ? String(profile.public_slug) : ''; - if (!slug || displayName !== String(profile.display_name)) { - const base = publicProfileSlugBase(displayName, userId); - slug = claimedProfileSlugs.has(base) - ? `${base.slice(0, 55).replace(/-+$/g, '')}~${userId.replaceAll('-', '').slice(0, 8).toLowerCase()}` - : base; - } - claimedProfileSlugs.add(slug); - await (await database.prepare(`UPDATE user_profiles - SET display_name=?,display_name_key=?,public_slug=? WHERE user_id=?`)) - .run(displayName, key, slug, userId); + const userId = String(profile.user_id); + let displayName = String(profile.display_name); + let key = displayNameKey(displayName); + if (claimedDisplayNameKeys.has(key)) { + for (let suffixLength = 5; suffixLength <= 12; suffixLength += 1) { + const candidate = `${displayName} ${userId.replaceAll('-', '').slice(0, suffixLength).toLowerCase()}`; + const candidateKey = displayNameKey(candidate); + if (!claimedDisplayNameKeys.has(candidateKey)) { + displayName = candidate; + key = candidateKey; + break; + } + } + } + claimedDisplayNameKeys.add(key); + let slug = profile.public_slug ? String(profile.public_slug) : ''; + if (!slug || displayName !== String(profile.display_name)) { + const base = publicProfileSlugBase(displayName, userId); + slug = claimedProfileSlugs.has(base) + ? `${base.slice(0, 55).replace(/-+$/g, '')}~${userId.replaceAll('-', '').slice(0, 8).toLowerCase()}` + : base; + } + claimedProfileSlugs.add(slug); + await ( + await database.prepare(`UPDATE user_profiles + SET display_name=?,display_name_key=?,public_slug=? WHERE user_id=?`) + ).run(displayName, key, slug, userId); } -await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS user_profiles_public_slug_idx ON user_profiles(public_slug)'); -await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS user_profiles_display_name_key_idx ON user_profiles(display_name_key)'); +await database.exec( + 'CREATE UNIQUE INDEX IF NOT EXISTS user_profiles_public_slug_idx ON user_profiles(public_slug)', +); +await database.exec( + 'CREATE UNIQUE INDEX IF NOT EXISTS user_profiles_display_name_key_idx ON user_profiles(display_name_key)', +); -const skillProfileDefinition = await (await database.prepare( - "SELECT sql FROM sqlite_master WHERE type='table' AND name='skill_attribute_calculation_profiles'" -)).get(); -if (typeof skillProfileDefinition?.sql === 'string' && !skillProfileDefinition.sql.includes("'flat_damage'")) { - console.log('Adding flat damage to versioned Skill calculation profiles.'); - await database.exec('PRAGMA foreign_keys=OFF'); - await database.exec('BEGIN'); - try { - await database.exec('ALTER TABLE skill_attribute_calculation_profiles RENAME TO skill_attribute_calculation_profiles_previous'); - await database.exec(`CREATE TABLE skill_attribute_calculation_profiles ( +const skillProfileDefinition = await ( + await database.prepare( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='skill_attribute_calculation_profiles'", + ) +).get(); +if ( + typeof skillProfileDefinition?.sql === 'string' && + !skillProfileDefinition.sql.includes("'flat_damage'") +) { + console.log('Adding flat damage to versioned Skill calculation profiles.'); + await database.exec('PRAGMA foreign_keys=OFF'); + await database.exec('BEGIN'); + try { + await database.exec( + 'ALTER TABLE skill_attribute_calculation_profiles RENAME TO skill_attribute_calculation_profiles_previous', + ); + await database.exec(`CREATE TABLE skill_attribute_calculation_profiles ( id TEXT PRIMARY KEY, revision_id TEXT NOT NULL REFERENCES calculation_revisions(id) ON DELETE CASCADE, stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id), @@ -515,26 +997,32 @@ if (typeof skillProfileDefinition?.sql === 'string' && !skillProfileDefinition.s CHECK(hard_cap IS NULL OR hard_cap>=0), CHECK(soft_cap IS NULL OR hard_cap IS NULL OR hard_cap>=soft_cap) )`); - await database.exec(`INSERT INTO skill_attribute_calculation_profiles + await database.exec(`INSERT INTO skill_attribute_calculation_profiles SELECT * FROM skill_attribute_calculation_profiles_previous`); - await database.exec('DROP TABLE skill_attribute_calculation_profiles_previous'); - await database.exec('COMMIT'); - } catch (error) { - await database.exec('ROLLBACK'); - throw error; - } finally { - await database.exec('PRAGMA foreign_keys=ON'); - } + await database.exec( + 'DROP TABLE skill_attribute_calculation_profiles_previous', + ); + await database.exec('COMMIT'); + } catch (error) { + await database.exec('ROLLBACK'); + throw error; + } finally { + await database.exec('PRAGMA foreign_keys=ON'); + } } -const triggerColumns = await (await database.prepare("PRAGMA table_info('talent_triggers')")).all(); +const triggerColumns = await ( + await database.prepare("PRAGMA table_info('talent_triggers')") +).all(); if (triggerColumns.some((column) => column.name === 'supports_uptime')) { - console.log('Replacing free-form trigger uptime fields with typed trigger actions.'); - await database.exec('PRAGMA foreign_keys=OFF'); - await database.exec('BEGIN'); - try { - await database.exec('DROP TABLE IF EXISTS talent_triggers_typed'); - await database.exec(`CREATE TABLE talent_triggers_typed ( + console.log( + 'Replacing free-form trigger uptime fields with typed trigger actions.', + ); + await database.exec('PRAGMA foreign_keys=OFF'); + await database.exec('BEGIN'); + try { + await database.exec('DROP TABLE IF EXISTS talent_triggers_typed'); + await database.exec(`CREATE TABLE talent_triggers_typed ( id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT NOT NULL, @@ -543,34 +1031,44 @@ if (triggerColumns.some((column) => column.name === 'supports_uptime')) { created_at TEXT NOT NULL, updated_at TEXT NOT NULL )`); - await database.exec(`INSERT INTO talent_triggers_typed(id,name,description,action_key,active,created_at,updated_at) + await database.exec(`INSERT INTO talent_triggers_typed(id,name,description,action_key,active,created_at,updated_at) SELECT id,name,description,NULL,active,created_at,updated_at FROM talent_triggers`); - await database.exec('DROP TABLE talent_triggers'); - await database.exec('ALTER TABLE talent_triggers_typed RENAME TO talent_triggers'); - await database.exec('COMMIT'); - } catch (error) { - await database.exec('ROLLBACK'); - throw error; - } finally { - await database.exec('PRAGMA foreign_keys=ON'); - } - await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS talent_triggers_name_nocase_idx ON talent_triggers(name COLLATE NOCASE)'); + await database.exec('DROP TABLE talent_triggers'); + await database.exec( + 'ALTER TABLE talent_triggers_typed RENAME TO talent_triggers', + ); + await database.exec('COMMIT'); + } catch (error) { + await database.exec('ROLLBACK'); + throw error; + } finally { + await database.exec('PRAGMA foreign_keys=ON'); + } + await database.exec( + 'CREATE UNIQUE INDEX IF NOT EXISTS talent_triggers_name_nocase_idx ON talent_triggers(name COLLATE NOCASE)', + ); } -const conditionalRuleColumns = await (await database.prepare( - "PRAGMA table_info('conditional_effect_rules')" -)).all(); +const conditionalRuleColumns = await ( + await database.prepare("PRAGMA table_info('conditional_effect_rules')") +).all(); if (conditionalRuleColumns.some((column) => column.name === 'trigger_id')) { - console.log('Migrating conditional effects to multi-clause mechanic programs.'); - await database.exec('PRAGMA foreign_keys=OFF'); - await database.exec('BEGIN'); - try { - await database.exec('DROP VIEW IF EXISTS conditional_effect_rule_primary'); - await database.exec('DROP TABLE IF EXISTS conditional_effect_clauses'); - await database.exec('DROP TABLE IF EXISTS conditional_effect_state_pools'); - await database.exec('ALTER TABLE conditional_effect_outcomes RENAME TO conditional_effect_outcomes_previous'); - await database.exec('ALTER TABLE conditional_effect_rules RENAME TO conditional_effect_rules_previous'); - await database.exec(`CREATE TABLE conditional_effect_rules ( + console.log( + 'Migrating conditional effects to multi-clause mechanic programs.', + ); + await database.exec('PRAGMA foreign_keys=OFF'); + await database.exec('BEGIN'); + try { + await database.exec('DROP VIEW IF EXISTS conditional_effect_rule_primary'); + await database.exec('DROP TABLE IF EXISTS conditional_effect_clauses'); + await database.exec('DROP TABLE IF EXISTS conditional_effect_state_pools'); + await database.exec( + 'ALTER TABLE conditional_effect_outcomes RENAME TO conditional_effect_outcomes_previous', + ); + await database.exec( + 'ALTER TABLE conditional_effect_rules RENAME TO conditional_effect_rules_previous', + ); + await database.exec(`CREATE TABLE conditional_effect_rules ( id TEXT PRIMARY KEY, source_type TEXT NOT NULL CHECK(source_type IN ( 'talent','os_protocol_talent','skill_chip_talent','specialization_tree_rank' @@ -586,7 +1084,7 @@ if (conditionalRuleColumns.some((column) => column.name === 'trigger_id')) { updated_at TEXT NOT NULL, UNIQUE(source_type,source_id) )`); - await database.exec(`CREATE TABLE conditional_effect_state_pools ( + await database.exec(`CREATE TABLE conditional_effect_state_pools ( id TEXT PRIMARY KEY, rule_id TEXT NOT NULL REFERENCES conditional_effect_rules(id) ON DELETE CASCADE, state_key TEXT NOT NULL, @@ -612,7 +1110,7 @@ if (conditionalRuleColumns.some((column) => column.name === 'trigger_id')) { CHECK(best_case_value IS NULL OR minimum_value IS NULL OR best_case_value>=minimum_value), CHECK(best_case_value IS NULL OR best_case_value<=maximum_value) )`); - await database.exec(`CREATE TABLE conditional_effect_clauses ( + await database.exec(`CREATE TABLE conditional_effect_clauses ( id TEXT PRIMARY KEY, rule_id TEXT NOT NULL REFERENCES conditional_effect_rules(id) ON DELETE CASCADE, clause_key TEXT NOT NULL, @@ -635,7 +1133,7 @@ if (conditionalRuleColumns.some((column) => column.name === 'trigger_id')) { UNIQUE(rule_id,clause_key), UNIQUE(rule_id,sort_order) )`); - await database.exec(`CREATE TABLE conditional_effect_outcomes ( + await database.exec(`CREATE TABLE conditional_effect_outcomes ( id TEXT PRIMARY KEY, rule_id TEXT NOT NULL REFERENCES conditional_effect_rules(id) ON DELETE CASCADE, clause_id TEXT REFERENCES conditional_effect_clauses(id) ON DELETE CASCADE, @@ -680,19 +1178,19 @@ if (conditionalRuleColumns.some((column) => column.name === 'trigger_id')) { ) ) )`); - await database.exec(`INSERT INTO conditional_effect_rules + await database.exec(`INSERT INTO conditional_effect_rules (id,source_type,source_id,configured,script_version,simulation_readiness, scenario_inputs_json,created_at,updated_at) SELECT id,source_type,source_id,configured,1,'ready','[]',created_at,updated_at FROM conditional_effect_rules_previous`); - await database.exec(`INSERT INTO conditional_effect_state_pools + await database.exec(`INSERT INTO conditional_effect_state_pools (id,rule_id,state_key,label,state_kind,state_scope,initial_value,minimum_value, maximum_value,best_case_value,duration_seconds,expiry_policy,refresh_policy, parameters_json,sort_order,created_at,updated_at) SELECT id||':effect',id,'effect','Effect','stack','agent',0,0,max_stacks,max_stacks, duration_seconds,'clear_all','refresh','{}',1,created_at,updated_at FROM conditional_effect_rules_previous`); - await database.exec(`INSERT INTO conditional_effect_clauses + await database.exec(`INSERT INTO conditional_effect_clauses (id,rule_id,clause_key,label,trigger_id,parameters_json,conditions_json,reducer_json, emission_kind,state_pool_id,mutations_json,cooldown_seconds,output_branch,sort_order, active,created_at,updated_at) @@ -702,7 +1200,7 @@ if (conditionalRuleColumns.some((column) => column.name === 'trigger_id')) { cooldown_seconds,'active',1,1,created_at,updated_at FROM conditional_effect_rules_previous WHERE trigger_id IS NOT NULL`); - await database.exec(`INSERT INTO conditional_effect_outcomes + await database.exec(`INSERT INTO conditional_effect_outcomes (id,rule_id,clause_id,state_pool_id,effect_key,variant_key,outcome_key,application_kind, effect_scope,stat_attribute_id,value,value_expression_json,value_basis,value_scaling, unit,damage_basis,application_phase,min_state_value,max_state_value,description_slot, @@ -720,22 +1218,22 @@ if (conditionalRuleColumns.some((column) => column.name === 'trigger_id')) { outcome.sort_order,outcome.created_at,outcome.created_at FROM conditional_effect_outcomes_previous outcome JOIN conditional_effect_rules_previous rule ON rule.id=outcome.rule_id`); - await database.exec('DROP TABLE conditional_effect_outcomes_previous'); - await database.exec('DROP TABLE conditional_effect_rules_previous'); - await database.exec('COMMIT'); - } catch (error) { - await database.exec('ROLLBACK'); - throw error; - } finally { - await database.exec('PRAGMA foreign_keys=ON'); - } - await database.exec(`CREATE INDEX IF NOT EXISTS conditional_effect_state_pools_rule_idx + await database.exec('DROP TABLE conditional_effect_outcomes_previous'); + await database.exec('DROP TABLE conditional_effect_rules_previous'); + await database.exec('COMMIT'); + } catch (error) { + await database.exec('ROLLBACK'); + throw error; + } finally { + await database.exec('PRAGMA foreign_keys=ON'); + } + await database.exec(`CREATE INDEX IF NOT EXISTS conditional_effect_state_pools_rule_idx ON conditional_effect_state_pools(rule_id,sort_order)`); - await database.exec(`CREATE INDEX IF NOT EXISTS conditional_effect_clauses_trigger_idx + await database.exec(`CREATE INDEX IF NOT EXISTS conditional_effect_clauses_trigger_idx ON conditional_effect_clauses(trigger_id,rule_id)`); - await database.exec(`CREATE INDEX IF NOT EXISTS conditional_effect_outcomes_rule_idx + await database.exec(`CREATE INDEX IF NOT EXISTS conditional_effect_outcomes_rule_idx ON conditional_effect_outcomes(rule_id,variant_key,outcome_key,sort_order)`); - await database.exec(`CREATE VIEW IF NOT EXISTS conditional_effect_rule_primary AS + await database.exec(`CREATE VIEW IF NOT EXISTS conditional_effect_rule_primary AS SELECT rule.*, clause.id AS clause_id, @@ -753,41 +1251,45 @@ if (conditionalRuleColumns.some((column) => column.name === 'trigger_id')) { ON pool.id=clause.state_pool_id`); } -const legacyTriggerClauseColumns = await (await database.prepare( - "PRAGMA table_info('conditional_effect_clauses')" -)).all(); +const legacyTriggerClauseColumns = await ( + await database.prepare("PRAGMA table_info('conditional_effect_clauses')") +).all(); if (legacyTriggerClauseColumns.some((column) => column.name === 'trigger_id')) { -const nowForConditionalMigration = new Date().toISOString(); -await (await database.prepare(`INSERT OR IGNORE INTO talent_triggers + const nowForConditionalMigration = new Date().toISOString(); + await ( + await database.prepare(`INSERT OR IGNORE INTO talent_triggers (id,name,description,action_key,active,created_at,updated_at) - VALUES(?,?,?,?,1,?,?)`)).run( - 'trigger-same-target-hits-window', - 'Same target hits within a time window', - 'Activates after the configured number of hits land on the same target within the configured window.', - 'same_target_hits_within_window', - nowForConditionalMigration, - nowForConditionalMigration -); -await database.exec(`INSERT OR IGNORE INTO conditional_effect_rules + VALUES(?,?,?,?,1,?,?)`) + ).run( + 'trigger-same-target-hits-window', + 'Same target hits within a time window', + 'Activates after the configured number of hits land on the same target within the configured window.', + 'same_target_hits_within_window', + nowForConditionalMigration, + nowForConditionalMigration, + ); + await database.exec(`INSERT OR IGNORE INTO conditional_effect_rules (id,source_type,source_id,configured,created_at,updated_at) SELECT 'conditional-talent-'||talent.id,'talent',talent.id,0, talent.created_at,talent.updated_at FROM talents talent`); -await database.exec(`INSERT OR IGNORE INTO conditional_effect_rules + await database.exec(`INSERT OR IGNORE INTO conditional_effect_rules (id,source_type,source_id,configured,created_at,updated_at) SELECT 'conditional-protocol-'||talent.id,'os_protocol_talent',talent.id,0, talent.created_at,talent.updated_at FROM os_protocol_talents talent`); -await database.exec(`INSERT OR IGNORE INTO conditional_effect_rules + await database.exec(`INSERT OR IGNORE INTO conditional_effect_rules (id,source_type,source_id,configured,created_at,updated_at) SELECT 'conditional-chip-'||talent.id,'skill_chip_talent',talent.id,0, talent.created_at,talent.updated_at FROM skill_chip_talents talent`); -await (await database.prepare(`INSERT OR IGNORE INTO conditional_effect_rules + await ( + await database.prepare(`INSERT OR IGNORE INTO conditional_effect_rules (id,source_type,source_id,configured,created_at,updated_at) SELECT 'conditional-specialization-'||attachment.id,'specialization_tree_rank',attachment.id,1,?,? - FROM specialization_tree_rank_combat_effects attachment`)).run(nowForConditionalMigration, nowForConditionalMigration); -await database.exec(`INSERT OR IGNORE INTO conditional_effect_state_pools + FROM specialization_tree_rank_combat_effects attachment`) + ).run(nowForConditionalMigration, nowForConditionalMigration); + await database.exec(`INSERT OR IGNORE INTO conditional_effect_state_pools (id,rule_id,state_key,label,state_kind,state_scope,initial_value,minimum_value, maximum_value,best_case_value,duration_seconds,expiry_policy,refresh_policy, parameters_json,sort_order,created_at,updated_at) @@ -796,7 +1298,7 @@ await database.exec(`INSERT OR IGNORE INTO conditional_effect_state_pools talent.duration_seconds,'clear_all','refresh','{}',1,talent.created_at,talent.updated_at FROM conditional_effect_rules rule JOIN talents talent ON rule.source_type='talent' AND rule.source_id=talent.id`); -await database.exec(`INSERT OR IGNORE INTO conditional_effect_state_pools + await database.exec(`INSERT OR IGNORE INTO conditional_effect_state_pools (id,rule_id,state_key,label,state_kind,state_scope,initial_value,minimum_value, maximum_value,best_case_value,duration_seconds,expiry_policy,refresh_policy, parameters_json,sort_order,created_at,updated_at) @@ -804,7 +1306,7 @@ await database.exec(`INSERT OR IGNORE INTO conditional_effect_state_pools 'clear_all','refresh','{}',1,talent.created_at,talent.updated_at FROM conditional_effect_rules rule JOIN os_protocol_talents talent ON rule.source_type='os_protocol_talent' AND rule.source_id=talent.id`); -await database.exec(`INSERT OR IGNORE INTO conditional_effect_state_pools + await database.exec(`INSERT OR IGNORE INTO conditional_effect_state_pools (id,rule_id,state_key,label,state_kind,state_scope,initial_value,minimum_value, maximum_value,best_case_value,duration_seconds,expiry_policy,refresh_policy, parameters_json,sort_order,created_at,updated_at) @@ -812,7 +1314,8 @@ await database.exec(`INSERT OR IGNORE INTO conditional_effect_state_pools 'clear_all','refresh','{}',1,talent.created_at,talent.updated_at FROM conditional_effect_rules rule JOIN skill_chip_talents talent ON rule.source_type='skill_chip_talent' AND rule.source_id=talent.id`); -await (await database.prepare(`INSERT OR IGNORE INTO conditional_effect_state_pools + await ( + await database.prepare(`INSERT OR IGNORE INTO conditional_effect_state_pools (id,rule_id,state_key,label,state_kind,state_scope,initial_value,minimum_value, maximum_value,best_case_value,duration_seconds,expiry_policy,refresh_policy, parameters_json,sort_order,created_at,updated_at) @@ -821,9 +1324,9 @@ await (await database.prepare(`INSERT OR IGNORE INTO conditional_effect_state_po 'clear_all','refresh','{}',1,?,? FROM conditional_effect_rules rule JOIN specialization_tree_rank_combat_effects attachment - ON rule.source_type='specialization_tree_rank' AND rule.source_id=attachment.id`)) - .run(nowForConditionalMigration, nowForConditionalMigration); -await database.exec(`INSERT OR IGNORE INTO conditional_effect_clauses + ON rule.source_type='specialization_tree_rank' AND rule.source_id=attachment.id`) + ).run(nowForConditionalMigration, nowForConditionalMigration); + await database.exec(`INSERT OR IGNORE INTO conditional_effect_clauses (id,rule_id,clause_key,label,trigger_id,parameters_json,conditions_json,reducer_json, emission_kind,state_pool_id,mutations_json,cooldown_seconds,output_branch,sort_order, active,created_at,updated_at) @@ -841,7 +1344,7 @@ await database.exec(`INSERT OR IGNORE INTO conditional_effect_clauses JOIN talent_tier_triggers link ON link.talent_tier_id=tier.id WHERE tier.talent_id=talent.id )`); -await database.exec(`INSERT OR IGNORE INTO conditional_effect_clauses + await database.exec(`INSERT OR IGNORE INTO conditional_effect_clauses (id,rule_id,clause_key,label,trigger_id,parameters_json,conditions_json,reducer_json, emission_kind,state_pool_id,mutations_json,cooldown_seconds,output_branch,sort_order, active,created_at,updated_at) @@ -852,7 +1355,7 @@ await database.exec(`INSERT OR IGNORE INTO conditional_effect_clauses FROM conditional_effect_rules rule JOIN os_protocol_talents talent ON rule.source_type='os_protocol_talent' AND rule.source_id=talent.id WHERE EXISTS (SELECT 1 FROM os_protocol_talent_triggers WHERE talent_id=talent.id)`); -await database.exec(`INSERT OR IGNORE INTO conditional_effect_clauses + await database.exec(`INSERT OR IGNORE INTO conditional_effect_clauses (id,rule_id,clause_key,label,trigger_id,parameters_json,conditions_json,reducer_json, emission_kind,state_pool_id,mutations_json,cooldown_seconds,output_branch,sort_order, active,created_at,updated_at) @@ -863,7 +1366,8 @@ await database.exec(`INSERT OR IGNORE INTO conditional_effect_clauses FROM conditional_effect_rules rule JOIN skill_chip_talents talent ON rule.source_type='skill_chip_talent' AND rule.source_id=talent.id WHERE EXISTS (SELECT 1 FROM skill_chip_talent_triggers WHERE talent_id=talent.id)`); -await (await database.prepare(`INSERT OR IGNORE INTO conditional_effect_clauses + await ( + await database.prepare(`INSERT OR IGNORE INTO conditional_effect_clauses (id,rule_id,clause_key,label,trigger_id,parameters_json,conditions_json,reducer_json, emission_kind,state_pool_id,mutations_json,cooldown_seconds,output_branch,sort_order, active,created_at,updated_at) @@ -876,16 +1380,16 @@ await (await database.prepare(`INSERT OR IGNORE INTO conditional_effect_clauses NULL,'active',1,1,?,? FROM conditional_effect_rules rule JOIN specialization_tree_rank_combat_effects attachment - ON rule.source_type='specialization_tree_rank' AND rule.source_id=attachment.id`)) - .run(nowForConditionalMigration, nowForConditionalMigration); -await database.exec(`UPDATE conditional_effect_clauses + ON rule.source_type='specialization_tree_rank' AND rule.source_id=attachment.id`) + ).run(nowForConditionalMigration, nowForConditionalMigration); + await database.exec(`UPDATE conditional_effect_clauses SET parameters_json=REPLACE(parameters_json,'"count":','"hitsRequired":') WHERE rule_id IN ( SELECT id FROM conditional_effect_rules WHERE source_type='specialization_tree_rank' AND configured=1 ) AND parameters_json LIKE '%"count":%'`); -await database.exec(`WITH canonical_talent_effects AS ( + await database.exec(`WITH canonical_talent_effects AS ( SELECT ranked.rule_id,ranked.stat_attribute_id, ROW_NUMBER() OVER( PARTITION BY ranked.rule_id @@ -923,7 +1427,7 @@ await database.exec(`WITH canonical_talent_effects AS ( JOIN canonical_talent_effects canonical ON canonical.rule_id=rule.id AND canonical.stat_attribute_id=effect.stat_attribute_id JOIN stat_attributes attribute ON attribute.id=effect.stat_attribute_id`); -await database.exec(`INSERT OR IGNORE INTO conditional_effect_outcomes + await database.exec(`INSERT OR IGNORE INTO conditional_effect_outcomes (id,rule_id,clause_id,state_pool_id,effect_key,variant_key,outcome_key,application_kind, effect_scope,stat_attribute_id,value,value_expression_json,value_basis,value_scaling, unit,damage_basis,application_phase,min_state_value,max_state_value,description_slot, @@ -936,7 +1440,7 @@ await database.exec(`INSERT OR IGNORE INTO conditional_effect_outcomes JOIN os_protocol_talents talent ON talent.id=effect.talent_id JOIN conditional_effect_rules rule ON rule.source_type='os_protocol_talent' AND rule.source_id=talent.id JOIN stat_attributes attribute ON attribute.id=effect.stat_attribute_id`); -await database.exec(`INSERT OR IGNORE INTO conditional_effect_outcomes + await database.exec(`INSERT OR IGNORE INTO conditional_effect_outcomes (id,rule_id,clause_id,state_pool_id,effect_key,variant_key,outcome_key,application_kind, effect_scope,stat_attribute_id,value,value_expression_json,value_basis,value_scaling, unit,damage_basis,application_phase,min_state_value,max_state_value,description_slot, @@ -949,7 +1453,8 @@ await database.exec(`INSERT OR IGNORE INTO conditional_effect_outcomes JOIN skill_chip_talents talent ON talent.id=effect.talent_id JOIN conditional_effect_rules rule ON rule.source_type='skill_chip_talent' AND rule.source_id=talent.id JOIN stat_attributes attribute ON attribute.id=effect.stat_attribute_id`); -await (await database.prepare(`INSERT OR IGNORE INTO conditional_effect_outcomes + await ( + await database.prepare(`INSERT OR IGNORE INTO conditional_effect_outcomes (id,rule_id,clause_id,state_pool_id,effect_key,variant_key,outcome_key,application_kind, effect_scope,stat_attribute_id,value,value_expression_json,value_basis,value_scaling, unit,damage_basis,application_phase,min_state_value,max_state_value,description_slot, @@ -961,187 +1466,226 @@ await (await database.prepare(`INSERT OR IGNORE INTO conditional_effect_outcomes FROM specialization_tree_rank_combat_modifiers modifier JOIN conditional_effect_rules rule ON rule.source_type='specialization_tree_rank' JOIN specialization_tree_rank_combat_effects attachment - ON attachment.id=rule.source_id AND attachment.id=modifier.rank_combat_effect_id`)) - .run(nowForConditionalMigration, nowForConditionalMigration); + ON attachment.id=rule.source_id AND attachment.id=modifier.rank_combat_effect_id`) + ).run(nowForConditionalMigration, nowForConditionalMigration); } -const sourceOwnedClauseColumns = await (await database.prepare( - "PRAGMA table_info('conditional_effect_clauses')" -)).all(); +const sourceOwnedClauseColumns = await ( + await database.prepare("PRAGMA table_info('conditional_effect_clauses')") +).all(); if (sourceOwnedClauseColumns.some((column) => column.name === 'trigger_id')) { - console.log('Migrating mechanic clauses from Trigger references to source-owned events.'); - const legacyClauses = await (await database.prepare(` + console.log( + 'Migrating mechanic clauses from Trigger references to source-owned events.', + ); + const legacyClauses = await ( + await database.prepare(` SELECT clause.*,trigger.action_key FROM conditional_effect_clauses clause LEFT JOIN talent_triggers trigger ON trigger.id=clause.trigger_id ORDER BY clause.rule_id,clause.sort_order - `)).all(); - const jsonObject = (value, fallback) => { - try { - const parsed = JSON.parse(String(value ?? '')); - return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : fallback; - } catch { - return fallback; - } - }; - const eventReference = (path) => ({ source: 'event', path }); - const targetExists = { kind: 'exists', value: eventReference('targetId') }; - const combinePredicates = (left, right) => { - if (!left || left.kind === 'true') return right ?? { kind: 'true' }; - if (!right || right.kind === 'true') return left; - return { kind: 'all', predicates: [left, right] }; - }; - const actionMapping = (action, parameters) => { - switch (action) { - case 'same_target_hits_within_window': - return { - eventKind: 'weapon_hit', - predicate: targetExists, - reducer: { - kind: 'sliding_count', - threshold: Number(parameters.hitsRequired ?? 1), - windowSeconds: Number(parameters.windowSeconds ?? 1), - reset: 'zero', - groupBy: eventReference('targetId') - } - }; - case 'distinct_enemies_within_window': - return { - eventKind: 'weapon_hit', - predicate: targetExists, - reducer: { - kind: 'distinct_count', - threshold: Number(parameters.enemiesRequired ?? 1), - windowSeconds: Number(parameters.windowSeconds ?? 1), - distinctBy: eventReference('targetId'), - reset: 'zero' - } - }; - case 'consecutive_same_target_hits': - return { - eventKind: 'weapon_hit', - predicate: targetExists, - reducer: { - kind: 'consecutive_count', - threshold: Number(parameters.hitsRequired ?? 1), - groupBy: eventReference('targetId'), - reset: 'zero' - } - }; - case 'continuous_firing_duration': - return { - eventKind: 'firing_started', - reducer: { kind: 'duration', seconds: Number(parameters.seconds ?? 1) }, - capturedOnly: true - }; - case 'while_firing': - return { eventKind: 'firing_started', capturedOnly: true }; - case 'health_threshold_bands': - return { eventKind: 'tick', capturedOnly: true }; - case 'headshot': - return { - eventKind: 'weapon_hit', - predicate: { - kind: 'compare', - left: eventReference('values.headshot'), - operator: 'eq', - right: true - } - }; - case 'headshot_kill': - return { - eventKind: 'enemy_killed', - predicate: { - kind: 'compare', - left: eventReference('values.headshot'), - operator: 'eq', - right: true - } - }; - case 'weapon_kill': - return { eventKind: 'enemy_killed' }; - case 'target_hit_within_distance': - return { - eventKind: 'weapon_hit', - predicate: { - kind: 'all', - predicates: [ - targetExists, - { - kind: 'compare', - left: eventReference('values.distanceMeters'), - operator: 'lte', - right: Number(parameters.meters ?? 0) - } - ] - } - }; - case 'damage_taken': - return { eventKind: 'health_damage_taken' }; - case 'weapon_swap': - return { eventKind: 'weapon_swapped' }; - case 'reload': - return { eventKind: 'reload_started' }; - case 'within_distance': - return { - eventKind: 'proximity_snapshot', - predicate: { - kind: 'compare', - left: eventReference('values.distanceMeters'), - operator: 'lte', - right: Number(parameters.meters ?? 0) - }, - capturedOnly: true - }; - case 'extra_health_active': - return { - eventKind: 'tick', - predicate: { - kind: 'compare', - left: { source: 'combat', path: 'values.extraHealth' }, - operator: 'gt', - right: 0 - }, - capturedOnly: true - }; - default: - if ([ - 'shot_fired','weapon_hit','skill_deployed','skill_hit','agent_hit', - 'health_damage_taken','damage_dealt','enemy_killed','assist','healing_applied', - 'extra_health_granted','movement_snapshot','proximity_snapshot','firing_started', - 'firing_stopped','reload_started','reload_completed','weapon_swapped','tick' - ].includes(String(action))) return { eventKind: String(action) }; - return null; - } - }; - const migratedClauses = legacyClauses.map((clause) => { - const parameters = jsonObject(clause.parameters_json, {}); - const existingPredicate = jsonObject(clause.conditions_json, { kind: 'true' }); - const existingReducer = jsonObject(clause.reducer_json, { kind: 'none' }); - const mapping = actionMapping(String(clause.action_key ?? ''), parameters); - if (!mapping) { - throw new Error(`Cannot migrate mechanic clause ${clause.id}: unsupported Trigger action ${clause.action_key ?? 'NULL'}.`); - } - return { - ...clause, - event_kind: mapping.eventKind, - recipe_key: String(clause.action_key ?? '') || null, - recipe_parameters_json: JSON.stringify(parameters), - conditions_json: JSON.stringify(combinePredicates(mapping.predicate, existingPredicate)), - reducer_json: JSON.stringify( - existingReducer.kind === 'none' && mapping.reducer ? mapping.reducer : existingReducer - ), - captured_only: Boolean(mapping.capturedOnly) - }; - }); + `) + ).all(); + const jsonObject = (value, fallback) => { + try { + const parsed = JSON.parse(String(value ?? '')); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? parsed + : fallback; + } catch { + return fallback; + } + }; + const eventReference = (path) => ({ source: 'event', path }); + const targetExists = { kind: 'exists', value: eventReference('targetId') }; + const combinePredicates = (left, right) => { + if (!left || left.kind === 'true') return right ?? { kind: 'true' }; + if (!right || right.kind === 'true') return left; + return { kind: 'all', predicates: [left, right] }; + }; + const actionMapping = (action, parameters) => { + switch (action) { + case 'same_target_hits_within_window': + return { + eventKind: 'weapon_hit', + predicate: targetExists, + reducer: { + kind: 'sliding_count', + threshold: Number(parameters.hitsRequired ?? 1), + windowSeconds: Number(parameters.windowSeconds ?? 1), + reset: 'zero', + groupBy: eventReference('targetId'), + }, + }; + case 'distinct_enemies_within_window': + return { + eventKind: 'weapon_hit', + predicate: targetExists, + reducer: { + kind: 'distinct_count', + threshold: Number(parameters.enemiesRequired ?? 1), + windowSeconds: Number(parameters.windowSeconds ?? 1), + distinctBy: eventReference('targetId'), + reset: 'zero', + }, + }; + case 'consecutive_same_target_hits': + return { + eventKind: 'weapon_hit', + predicate: targetExists, + reducer: { + kind: 'consecutive_count', + threshold: Number(parameters.hitsRequired ?? 1), + groupBy: eventReference('targetId'), + reset: 'zero', + }, + }; + case 'continuous_firing_duration': + return { + eventKind: 'firing_started', + reducer: { + kind: 'duration', + seconds: Number(parameters.seconds ?? 1), + }, + capturedOnly: true, + }; + case 'while_firing': + return { eventKind: 'firing_started', capturedOnly: true }; + case 'health_threshold_bands': + return { eventKind: 'tick', capturedOnly: true }; + case 'headshot': + return { + eventKind: 'weapon_hit', + predicate: { + kind: 'compare', + left: eventReference('values.headshot'), + operator: 'eq', + right: true, + }, + }; + case 'headshot_kill': + return { + eventKind: 'enemy_killed', + predicate: { + kind: 'compare', + left: eventReference('values.headshot'), + operator: 'eq', + right: true, + }, + }; + case 'weapon_kill': + return { eventKind: 'enemy_killed' }; + case 'target_hit_within_distance': + return { + eventKind: 'weapon_hit', + predicate: { + kind: 'all', + predicates: [ + targetExists, + { + kind: 'compare', + left: eventReference('values.distanceMeters'), + operator: 'lte', + right: Number(parameters.meters ?? 0), + }, + ], + }, + }; + case 'damage_taken': + return { eventKind: 'health_damage_taken' }; + case 'weapon_swap': + return { eventKind: 'weapon_swapped' }; + case 'reload': + return { eventKind: 'reload_started' }; + case 'within_distance': + return { + eventKind: 'proximity_snapshot', + predicate: { + kind: 'compare', + left: eventReference('values.distanceMeters'), + operator: 'lte', + right: Number(parameters.meters ?? 0), + }, + capturedOnly: true, + }; + case 'extra_health_active': + return { + eventKind: 'tick', + predicate: { + kind: 'compare', + left: { source: 'combat', path: 'values.extraHealth' }, + operator: 'gt', + right: 0, + }, + capturedOnly: true, + }; + default: + if ( + [ + 'shot_fired', + 'weapon_hit', + 'skill_deployed', + 'skill_hit', + 'agent_hit', + 'health_damage_taken', + 'damage_dealt', + 'enemy_killed', + 'assist', + 'healing_applied', + 'extra_health_granted', + 'movement_snapshot', + 'proximity_snapshot', + 'firing_started', + 'firing_stopped', + 'reload_started', + 'reload_completed', + 'weapon_swapped', + 'tick', + ].includes(String(action)) + ) + return { eventKind: String(action) }; + return null; + } + }; + const migratedClauses = legacyClauses.map((clause) => { + const parameters = jsonObject(clause.parameters_json, {}); + const existingPredicate = jsonObject(clause.conditions_json, { + kind: 'true', + }); + const existingReducer = jsonObject(clause.reducer_json, { kind: 'none' }); + const mapping = actionMapping(String(clause.action_key ?? ''), parameters); + if (!mapping) { + throw new Error( + `Cannot migrate mechanic clause ${clause.id}: unsupported Trigger action ${clause.action_key ?? 'NULL'}.`, + ); + } + return { + ...clause, + event_kind: mapping.eventKind, + recipe_key: String(clause.action_key ?? '') || null, + recipe_parameters_json: JSON.stringify(parameters), + conditions_json: JSON.stringify( + combinePredicates(mapping.predicate, existingPredicate), + ), + reducer_json: JSON.stringify( + existingReducer.kind === 'none' && mapping.reducer + ? mapping.reducer + : existingReducer, + ), + captured_only: Boolean(mapping.capturedOnly), + }; + }); - await database.exec('PRAGMA foreign_keys=OFF'); - await database.exec('BEGIN'); - try { - await database.exec('DROP VIEW IF EXISTS conditional_effect_rule_primary'); - await database.exec('ALTER TABLE conditional_effect_clauses RENAME TO conditional_effect_clauses_triggered'); - await database.exec('ALTER TABLE conditional_effect_outcomes RENAME TO conditional_effect_outcomes_triggered'); - await database.exec(`CREATE TABLE conditional_effect_clauses ( + await database.exec('PRAGMA foreign_keys=OFF'); + await database.exec('BEGIN'); + try { + await database.exec('DROP VIEW IF EXISTS conditional_effect_rule_primary'); + await database.exec( + 'ALTER TABLE conditional_effect_clauses RENAME TO conditional_effect_clauses_triggered', + ); + await database.exec( + 'ALTER TABLE conditional_effect_outcomes RENAME TO conditional_effect_outcomes_triggered', + ); + await database.exec(`CREATE TABLE conditional_effect_clauses ( id TEXT PRIMARY KEY, rule_id TEXT NOT NULL REFERENCES conditional_effect_rules(id) ON DELETE CASCADE, clause_key TEXT NOT NULL, @@ -1166,40 +1710,43 @@ if (sourceOwnedClauseColumns.some((column) => column.name === 'trigger_id')) { UNIQUE(rule_id,clause_key), UNIQUE(rule_id,sort_order) )`); - const insertClause = await database.prepare(`INSERT INTO conditional_effect_clauses + const insertClause = + await database.prepare(`INSERT INTO conditional_effect_clauses (id,rule_id,clause_key,label,event_kind,recipe_key,recipe_parameters_json, conditions_json,reducer_json,weight_expression_json,emission_kind,state_pool_id, mutations_json,cooldown_seconds,output_branch,sort_order,active,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`); - for (const clause of migratedClauses) { - await insertClause.run( - clause.id, - clause.rule_id, - clause.clause_key, - clause.label, - clause.event_kind, - clause.recipe_key, - clause.recipe_parameters_json, - clause.conditions_json, - clause.reducer_json, - null, - clause.emission_kind, - clause.state_pool_id, - clause.mutations_json, - clause.cooldown_seconds, - clause.output_branch, - clause.sort_order, - clause.active, - clause.created_at, - clause.updated_at - ); - if (clause.captured_only) { - await (await database.prepare( - "UPDATE conditional_effect_rules SET simulation_readiness='captured_only' WHERE id=?" - )).run(clause.rule_id); - } - } - await database.exec(`CREATE TABLE conditional_effect_outcomes ( + for (const clause of migratedClauses) { + await insertClause.run( + clause.id, + clause.rule_id, + clause.clause_key, + clause.label, + clause.event_kind, + clause.recipe_key, + clause.recipe_parameters_json, + clause.conditions_json, + clause.reducer_json, + null, + clause.emission_kind, + clause.state_pool_id, + clause.mutations_json, + clause.cooldown_seconds, + clause.output_branch, + clause.sort_order, + clause.active, + clause.created_at, + clause.updated_at, + ); + if (clause.captured_only) { + await ( + await database.prepare( + "UPDATE conditional_effect_rules SET simulation_readiness='captured_only' WHERE id=?", + ) + ).run(clause.rule_id); + } + } + await database.exec(`CREATE TABLE conditional_effect_outcomes ( id TEXT PRIMARY KEY, rule_id TEXT NOT NULL REFERENCES conditional_effect_rules(id) ON DELETE CASCADE, clause_id TEXT REFERENCES conditional_effect_clauses(id) ON DELETE CASCADE, @@ -1249,7 +1796,7 @@ if (sourceOwnedClauseColumns.some((column) => column.name === 'trigger_id')) { ) ) )`); - await database.exec(`INSERT INTO conditional_effect_outcomes + await database.exec(`INSERT INTO conditional_effect_outcomes (id,rule_id,clause_id,state_pool_id,effect_key,variant_key,outcome_key, application_kind,effect_scope,stat_attribute_id,value,value_expression_json, value_basis,value_scaling,unit,damage_basis,application_phase,combat_timing, @@ -1266,22 +1813,22 @@ if (sourceOwnedClauseColumns.some((column) => column.name === 'trigger_id')) { ELSE NULL END, min_state_value,max_state_value,description_slot,sort_order,created_at,updated_at FROM conditional_effect_outcomes_triggered`); - await database.exec('DROP TABLE conditional_effect_outcomes_triggered'); - await database.exec('DROP TABLE conditional_effect_clauses_triggered'); - await database.exec('DROP TABLE IF EXISTS talent_tier_triggers'); - await database.exec('DROP TABLE IF EXISTS os_protocol_talent_triggers'); - await database.exec('DROP TABLE IF EXISTS skill_chip_talent_triggers'); - await database.exec('DROP TABLE IF EXISTS talent_triggers'); - await database.exec('COMMIT'); - } catch (error) { - await database.exec('ROLLBACK'); - throw error; - } finally { - await database.exec('PRAGMA foreign_keys=ON'); - } - await database.exec(`CREATE INDEX IF NOT EXISTS conditional_effect_clauses_event_idx + await database.exec('DROP TABLE conditional_effect_outcomes_triggered'); + await database.exec('DROP TABLE conditional_effect_clauses_triggered'); + await database.exec('DROP TABLE IF EXISTS talent_tier_triggers'); + await database.exec('DROP TABLE IF EXISTS os_protocol_talent_triggers'); + await database.exec('DROP TABLE IF EXISTS skill_chip_talent_triggers'); + await database.exec('DROP TABLE IF EXISTS talent_triggers'); + await database.exec('COMMIT'); + } catch (error) { + await database.exec('ROLLBACK'); + throw error; + } finally { + await database.exec('PRAGMA foreign_keys=ON'); + } + await database.exec(`CREATE INDEX IF NOT EXISTS conditional_effect_clauses_event_idx ON conditional_effect_clauses(event_kind,rule_id)`); - await database.exec(`CREATE INDEX IF NOT EXISTS conditional_effect_outcomes_rule_idx + await database.exec(`CREATE INDEX IF NOT EXISTS conditional_effect_outcomes_rule_idx ON conditional_effect_outcomes(rule_id,variant_key,outcome_key,sort_order)`); } @@ -1289,22 +1836,29 @@ if (sourceOwnedClauseColumns.some((column) => column.name === 'trigger_id')) { // Recheck after those migrations so their replacement tables receive the // additive columns too. const postRebuildOutcomeColumns = await ( - await database.prepare("PRAGMA table_info('conditional_effect_outcomes')") + await database.prepare("PRAGMA table_info('conditional_effect_outcomes')") ).all(); -if (!postRebuildOutcomeColumns.some((column) => column.name === 'delivery_mode')) { - console.log('Adding outcome delivery modes to conditional mechanics.'); - await database.exec(`ALTER TABLE conditional_effect_outcomes +if ( + !postRebuildOutcomeColumns.some((column) => column.name === 'delivery_mode') +) { + console.log('Adding outcome delivery modes to conditional mechanics.'); + await database.exec(`ALTER TABLE conditional_effect_outcomes ADD COLUMN delivery_mode TEXT NOT NULL DEFAULT 'instant' CHECK(delivery_mode IN ('instant','continuous'))`); } -if (!postRebuildOutcomeColumns.some((column) => column.name === 'delivery_duration_seconds')) { - console.log('Adding continuous-delivery durations to conditional mechanics.'); - await database.exec(`ALTER TABLE conditional_effect_outcomes +if ( + !postRebuildOutcomeColumns.some( + (column) => column.name === 'delivery_duration_seconds', + ) +) { + console.log('Adding continuous-delivery durations to conditional mechanics.'); + await database.exec(`ALTER TABLE conditional_effect_outcomes ADD COLUMN delivery_duration_seconds REAL CHECK(delivery_duration_seconds IS NULL OR delivery_duration_seconds>0)`); } -const legacyTalentOutcomes = await (await database.prepare(` +const legacyTalentOutcomes = await ( + await database.prepare(` SELECT outcome.id,outcome.rule_id,outcome.variant_key,outcome.stat_attribute_id, outcome.effect_key,outcome.description_slot,outcome.sort_order FROM conditional_effect_outcomes outcome @@ -1313,149 +1867,194 @@ const legacyTalentOutcomes = await (await database.prepare(` AND outcome.id LIKE 'conditional-outcome-talent-%' AND outcome.variant_key IN ('I','II') ORDER BY outcome.rule_id,outcome.variant_key,outcome.sort_order,outcome.id -`)).all(); +`) +).all(); const legacyTalentOutcomesByRule = new Map(); for (const outcome of legacyTalentOutcomes) { - const rows = legacyTalentOutcomesByRule.get(outcome.rule_id) ?? []; - rows.push(outcome); - legacyTalentOutcomesByRule.set(outcome.rule_id, rows); + const rows = legacyTalentOutcomesByRule.get(outcome.rule_id) ?? []; + rows.push(outcome); + legacyTalentOutcomesByRule.set(outcome.rule_id, rows); } const legacyTalentOutcomeRepairs = []; for (const [ruleId, rows] of legacyTalentOutcomesByRule) { - const tierOne = rows - .filter((row) => row.variant_key === 'I') - .sort((left, right) => - Number(left.description_slot ?? left.sort_order) - Number(right.description_slot ?? right.sort_order) - || String(left.id).localeCompare(String(right.id))); - const tierTwo = rows.filter((row) => row.variant_key === 'II'); - if (!tierOne.length || !tierTwo.length) continue; - const tierOneAttributes = new Set(tierOne.map((row) => String(row.stat_attribute_id ?? ''))); - const tierTwoAttributes = new Set(tierTwo.map((row) => String(row.stat_attribute_id ?? ''))); - if ( - tierOneAttributes.size !== tierTwoAttributes.size - || [...tierOneAttributes].some((attributeId) => !tierTwoAttributes.has(attributeId)) - ) { - throw new Error(`Cannot repair legacy talent mechanic ${ruleId}: Tier I and Tier II Attributes differ.`); - } - const slotByAttribute = new Map( - tierOne.map((row, index) => [String(row.stat_attribute_id), index + 1]) - ); - for (const row of rows) { - const slot = slotByAttribute.get(String(row.stat_attribute_id)); - if (!slot) continue; - if ( - row.effect_key !== `active-effect-${slot}` - || Number(row.description_slot) !== slot - || Number(row.sort_order) !== slot - ) { - legacyTalentOutcomeRepairs.push({ id: row.id, slot }); - } - } + const tierOne = rows + .filter((row) => row.variant_key === 'I') + .sort( + (left, right) => + Number(left.description_slot ?? left.sort_order) - + Number(right.description_slot ?? right.sort_order) || + String(left.id).localeCompare(String(right.id)), + ); + const tierTwo = rows.filter((row) => row.variant_key === 'II'); + if (!tierOne.length || !tierTwo.length) continue; + const tierOneAttributes = new Set( + tierOne.map((row) => String(row.stat_attribute_id ?? '')), + ); + const tierTwoAttributes = new Set( + tierTwo.map((row) => String(row.stat_attribute_id ?? '')), + ); + if ( + tierOneAttributes.size !== tierTwoAttributes.size || + [...tierOneAttributes].some( + (attributeId) => !tierTwoAttributes.has(attributeId), + ) + ) { + throw new Error( + `Cannot repair legacy talent mechanic ${ruleId}: Tier I and Tier II Attributes differ.`, + ); + } + const slotByAttribute = new Map( + tierOne.map((row, index) => [String(row.stat_attribute_id), index + 1]), + ); + for (const row of rows) { + const slot = slotByAttribute.get(String(row.stat_attribute_id)); + if (!slot) continue; + if ( + row.effect_key !== `active-effect-${slot}` || + Number(row.description_slot) !== slot || + Number(row.sort_order) !== slot + ) { + legacyTalentOutcomeRepairs.push({ id: row.id, slot }); + } + } } if (legacyTalentOutcomeRepairs.length) { - console.log(`Repairing ${legacyTalentOutcomeRepairs.length} legacy Talent mechanic value pairings.`); - await database.exec('BEGIN'); - try { - const temporaryOrder = await database.prepare( - 'UPDATE conditional_effect_outcomes SET sort_order=sort_order+1000000 WHERE id=?' - ); - for (const repair of legacyTalentOutcomeRepairs) await temporaryOrder.run(repair.id); - const repairOutcome = await database.prepare(` + console.log( + `Repairing ${legacyTalentOutcomeRepairs.length} legacy Talent mechanic value pairings.`, + ); + await database.exec('BEGIN'); + try { + const temporaryOrder = await database.prepare( + 'UPDATE conditional_effect_outcomes SET sort_order=sort_order+1000000 WHERE id=?', + ); + for (const repair of legacyTalentOutcomeRepairs) + await temporaryOrder.run(repair.id); + const repairOutcome = await database.prepare(` UPDATE conditional_effect_outcomes SET effect_key=?,description_slot=?,sort_order=?,updated_at=? WHERE id=? `); - const repairedAt = new Date().toISOString(); - for (const repair of legacyTalentOutcomeRepairs) { - await repairOutcome.run( - `active-effect-${repair.slot}`, - repair.slot, - repair.slot, - repairedAt, - repair.id - ); - } - await database.exec('COMMIT'); - } catch (error) { - await database.exec('ROLLBACK'); - throw error; - } + const repairedAt = new Date().toISOString(); + for (const repair of legacyTalentOutcomeRepairs) { + await repairOutcome.run( + `active-effect-${repair.slot}`, + repair.slot, + repair.slot, + repairedAt, + repair.id, + ); + } + await database.exec('COMMIT'); + } catch (error) { + await database.exec('ROLLBACK'); + throw error; + } } -const loginEventsDefinition = await (await database.prepare( - "SELECT sql FROM sqlite_master WHERE type='table' AND name='login_events'" -)).get(); -if (typeof loginEventsDefinition?.sql === 'string' && !loginEventsDefinition.sql.includes('magic_link')) { - console.log('Expanding login event providers for passwordless authentication.'); - await database.exec('ALTER TABLE login_events RENAME TO login_events_legacy'); - await database.exec(`CREATE TABLE login_events ( +const loginEventsDefinition = await ( + await database.prepare( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='login_events'", + ) +).get(); +if ( + typeof loginEventsDefinition?.sql === 'string' && + !loginEventsDefinition.sql.includes('magic_link') +) { + console.log( + 'Expanding login event providers for passwordless authentication.', + ); + await database.exec('ALTER TABLE login_events RENAME TO login_events_legacy'); + await database.exec(`CREATE TABLE login_events ( id TEXT PRIMARY KEY, user_id TEXT REFERENCES users(id) ON DELETE SET NULL, provider TEXT NOT NULL CHECK(provider IN ('password', 'discord', 'magic_link', 'passkey')), succeeded INTEGER NOT NULL CHECK(succeeded IN (0, 1)), created_at TEXT NOT NULL )`); - await database.exec(`INSERT INTO login_events(id,user_id,provider,succeeded,created_at) + await database.exec(`INSERT INTO login_events(id,user_id,provider,succeeded,created_at) SELECT id,user_id,provider,succeeded,created_at FROM login_events_legacy`); - await database.exec('DROP TABLE login_events_legacy'); - await database.exec('CREATE INDEX IF NOT EXISTS login_events_user_created_idx ON login_events(user_id, created_at DESC)'); + await database.exec('DROP TABLE login_events_legacy'); + await database.exec( + 'CREATE INDEX IF NOT EXISTS login_events_user_created_idx ON login_events(user_id, created_at DESC)', + ); } -const loginEventColumns = await (await database.prepare("PRAGMA table_info('login_events')")).all(); +const loginEventColumns = await ( + await database.prepare("PRAGMA table_info('login_events')") +).all(); for (const [name, definition] of [ - ['ip_address', 'TEXT'], - ['user_agent', 'TEXT'], - ['client_name', 'TEXT'], - ['operating_system', 'TEXT'], - ['device_type', 'TEXT'], - ['location_country', 'TEXT'], - ['location_region', 'TEXT'], - ['location_city', 'TEXT'], - ['failure_reason', 'TEXT'], - ['risk_flags', 'TEXT'], - ['credential_id', 'TEXT'], - ['session_id', 'TEXT REFERENCES sessions(id) ON DELETE SET NULL'], - ['session_ended_at', 'TEXT'], - ['session_end_reason', 'TEXT'], - ['sensitive_purged_at', 'TEXT'] + ['ip_address', 'TEXT'], + ['user_agent', 'TEXT'], + ['client_name', 'TEXT'], + ['operating_system', 'TEXT'], + ['device_type', 'TEXT'], + ['location_country', 'TEXT'], + ['location_region', 'TEXT'], + ['location_city', 'TEXT'], + ['failure_reason', 'TEXT'], + ['risk_flags', 'TEXT'], + ['credential_id', 'TEXT'], + ['session_id', 'TEXT REFERENCES sessions(id) ON DELETE SET NULL'], + ['session_ended_at', 'TEXT'], + ['session_end_reason', 'TEXT'], + ['sensitive_purged_at', 'TEXT'], ]) { - if (!loginEventColumns.some((column) => column.name === name)) { - await database.exec(`ALTER TABLE login_events ADD COLUMN ${name} ${definition}`); - } + if (!loginEventColumns.some((column) => column.name === name)) { + await database.exec( + `ALTER TABLE login_events ADD COLUMN ${name} ${definition}`, + ); + } } -const proposalColumns = await (await database.prepare("PRAGMA table_info('data_change_proposals')")).all(); +const proposalColumns = await ( + await database.prepare("PRAGMA table_info('data_change_proposals')") +).all(); for (const [name, definition] of [ - ['draft_entity_id', 'TEXT'], - ['applied_entity_id', 'TEXT'] + ['draft_entity_id', 'TEXT'], + ['applied_entity_id', 'TEXT'], ]) { - if (!proposalColumns.some((column) => column.name === name)) { - await database.exec(`ALTER TABLE data_change_proposals ADD COLUMN ${name} ${definition}`); - } + if (!proposalColumns.some((column) => column.name === name)) { + await database.exec( + `ALTER TABLE data_change_proposals ADD COLUMN ${name} ${definition}`, + ); + } } -await database.exec("UPDATE data_change_proposals SET draft_entity_id=id WHERE draft_entity_id IS NULL AND entity_id IS NULL"); -await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS data_change_proposals_draft_entity_idx ON data_change_proposals(draft_entity_id) WHERE draft_entity_id IS NOT NULL'); +await database.exec( + 'UPDATE data_change_proposals SET draft_entity_id=id WHERE draft_entity_id IS NULL AND entity_id IS NULL', +); +await database.exec( + 'CREATE UNIQUE INDEX IF NOT EXISTS data_change_proposals_draft_entity_idx ON data_change_proposals(draft_entity_id) WHERE draft_entity_id IS NOT NULL', +); -const notificationColumns = await (await database.prepare("PRAGMA table_info('notifications')")).all(); +const notificationColumns = await ( + await database.prepare("PRAGMA table_info('notifications')") +).all(); if (!notificationColumns.some((column) => column.name === 'actions_json')) { - console.log('Adding structured action callouts to notifications.'); - await database.exec('ALTER TABLE notifications ADD COLUMN actions_json TEXT'); + console.log('Adding structured action callouts to notifications.'); + await database.exec('ALTER TABLE notifications ADD COLUMN actions_json TEXT'); } -const legacyProtocolEffects = await (await database.prepare( - "SELECT 1 AS present FROM sqlite_master WHERE type='table' AND name='os_protocol_attribute_effects'" -)).get(); +const legacyProtocolEffects = await ( + await database.prepare( + "SELECT 1 AS present FROM sqlite_master WHERE type='table' AND name='os_protocol_attribute_effects'", + ) +).get(); if (legacyProtocolEffects) { - console.log('Removing the retired OS Protocol attribute-effects table.'); - await database.exec('DROP TABLE os_protocol_attribute_effects'); + console.log('Removing the retired OS Protocol attribute-effects table.'); + await database.exec('DROP TABLE os_protocol_attribute_effects'); } -const proposalDefinition = await (await database.prepare( - "SELECT sql FROM sqlite_master WHERE type='table' AND name='data_change_proposals'" -)).get(); -if (typeof proposalDefinition?.sql === 'string' && !proposalDefinition.sql.includes("'configuration'")) { - console.log('Expanding moderated data modules for Global Configuration.'); - await database.exec('PRAGMA foreign_keys=OFF'); - await database.exec(`CREATE TABLE data_change_proposals_expanded ( +const proposalDefinition = await ( + await database.prepare( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='data_change_proposals'", + ) +).get(); +if ( + typeof proposalDefinition?.sql === 'string' && + !proposalDefinition.sql.includes("'configuration'") +) { + console.log('Expanding moderated data modules for Global Configuration.'); + await database.exec('PRAGMA foreign_keys=OFF'); + await database.exec(`CREATE TABLE data_change_proposals_expanded ( id TEXT PRIMARY KEY, module TEXT NOT NULL CHECK(module IN ('configuration','attributes','talents','brands','gear','weapons','protocols','skillchips')), action TEXT NOT NULL, @@ -1472,24 +2071,37 @@ if (typeof proposalDefinition?.sql === 'string' && !proposalDefinition.sql.inclu reviewed_at TEXT, applied_at TEXT )`); - await database.exec(`INSERT INTO data_change_proposals_expanded + await database.exec(`INSERT INTO data_change_proposals_expanded SELECT id,CASE module WHEN 'stats' THEN 'attributes' ELSE module END,action,entity_id,draft_entity_id,applied_entity_id,summary,payload_json,status,proposed_by,reviewed_by,review_note,created_at,reviewed_at,applied_at FROM data_change_proposals`); - await database.exec('DROP TABLE data_change_proposals'); - await database.exec('ALTER TABLE data_change_proposals_expanded RENAME TO data_change_proposals'); - await database.exec('PRAGMA foreign_keys=ON'); + await database.exec('DROP TABLE data_change_proposals'); + await database.exec( + 'ALTER TABLE data_change_proposals_expanded RENAME TO data_change_proposals', + ); + await database.exec('PRAGMA foreign_keys=ON'); } -await database.exec('CREATE INDEX IF NOT EXISTS data_change_proposals_status_created_idx ON data_change_proposals(status,created_at DESC)'); -await database.exec('CREATE INDEX IF NOT EXISTS data_change_proposals_module_status_idx ON data_change_proposals(module,status)'); -await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS data_change_proposals_draft_entity_idx ON data_change_proposals(draft_entity_id) WHERE draft_entity_id IS NOT NULL'); +await database.exec( + 'CREATE INDEX IF NOT EXISTS data_change_proposals_status_created_idx ON data_change_proposals(status,created_at DESC)', +); +await database.exec( + 'CREATE INDEX IF NOT EXISTS data_change_proposals_module_status_idx ON data_change_proposals(module,status)', +); +await database.exec( + 'CREATE UNIQUE INDEX IF NOT EXISTS data_change_proposals_draft_entity_idx ON data_change_proposals(draft_entity_id) WHERE draft_entity_id IS NOT NULL', +); -const weaponModProposalDefinition = await (await database.prepare( - "SELECT sql FROM sqlite_master WHERE type='table' AND name='data_change_proposals'" -)).get(); -if (typeof weaponModProposalDefinition?.sql === 'string' && !weaponModProposalDefinition.sql.includes("'weaponmods'")) { - console.log('Adding Weapon Mods to moderated data modules.'); - await database.exec('PRAGMA foreign_keys=OFF'); - await database.exec(`CREATE TABLE data_change_proposals_weaponmods ( +const weaponModProposalDefinition = await ( + await database.prepare( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='data_change_proposals'", + ) +).get(); +if ( + typeof weaponModProposalDefinition?.sql === 'string' && + !weaponModProposalDefinition.sql.includes("'weaponmods'") +) { + console.log('Adding Weapon Mods to moderated data modules.'); + await database.exec('PRAGMA foreign_keys=OFF'); + await database.exec(`CREATE TABLE data_change_proposals_weaponmods ( id TEXT PRIMARY KEY, module TEXT NOT NULL CHECK(module IN ('configuration','attributes','talents','brands','gear','weapons','weaponmods','protocols','skillchips')), action TEXT NOT NULL, @@ -1506,24 +2118,37 @@ if (typeof weaponModProposalDefinition?.sql === 'string' && !weaponModProposalDe reviewed_at TEXT, applied_at TEXT )`); - await database.exec(`INSERT INTO data_change_proposals_weaponmods + await database.exec(`INSERT INTO data_change_proposals_weaponmods SELECT id,module,action,entity_id,draft_entity_id,applied_entity_id,summary,payload_json,status,proposed_by,reviewed_by,review_note,created_at,reviewed_at,applied_at FROM data_change_proposals`); - await database.exec('DROP TABLE data_change_proposals'); - await database.exec('ALTER TABLE data_change_proposals_weaponmods RENAME TO data_change_proposals'); - await database.exec('PRAGMA foreign_keys=ON'); - await database.exec('CREATE INDEX IF NOT EXISTS data_change_proposals_status_created_idx ON data_change_proposals(status,created_at DESC)'); - await database.exec('CREATE INDEX IF NOT EXISTS data_change_proposals_module_status_idx ON data_change_proposals(module,status)'); - await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS data_change_proposals_draft_entity_idx ON data_change_proposals(draft_entity_id) WHERE draft_entity_id IS NOT NULL'); + await database.exec('DROP TABLE data_change_proposals'); + await database.exec( + 'ALTER TABLE data_change_proposals_weaponmods RENAME TO data_change_proposals', + ); + await database.exec('PRAGMA foreign_keys=ON'); + await database.exec( + 'CREATE INDEX IF NOT EXISTS data_change_proposals_status_created_idx ON data_change_proposals(status,created_at DESC)', + ); + await database.exec( + 'CREATE INDEX IF NOT EXISTS data_change_proposals_module_status_idx ON data_change_proposals(module,status)', + ); + await database.exec( + 'CREATE UNIQUE INDEX IF NOT EXISTS data_change_proposals_draft_entity_idx ON data_change_proposals(draft_entity_id) WHERE draft_entity_id IS NOT NULL', + ); } -const specializationProposalDefinition = await (await database.prepare( - "SELECT sql FROM sqlite_master WHERE type='table' AND name='data_change_proposals'" -)).get(); -if (typeof specializationProposalDefinition?.sql === 'string' && !specializationProposalDefinition.sql.includes("'specializations'")) { - console.log('Adding Specializations to moderated data modules.'); - await database.exec('PRAGMA foreign_keys=OFF'); - await database.exec(`CREATE TABLE data_change_proposals_specializations ( +const specializationProposalDefinition = await ( + await database.prepare( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='data_change_proposals'", + ) +).get(); +if ( + typeof specializationProposalDefinition?.sql === 'string' && + !specializationProposalDefinition.sql.includes("'specializations'") +) { + console.log('Adding Specializations to moderated data modules.'); + await database.exec('PRAGMA foreign_keys=OFF'); + await database.exec(`CREATE TABLE data_change_proposals_specializations ( id TEXT PRIMARY KEY, module TEXT NOT NULL CHECK(module IN ('configuration','attributes','talents','brands','gear','weapons','weaponmods','gearmods','protocols','skillchips','specializations')), action TEXT NOT NULL, @@ -1540,24 +2165,37 @@ if (typeof specializationProposalDefinition?.sql === 'string' && !specialization reviewed_at TEXT, applied_at TEXT )`); - await database.exec(`INSERT INTO data_change_proposals_specializations + await database.exec(`INSERT INTO data_change_proposals_specializations SELECT id,module,action,entity_id,draft_entity_id,applied_entity_id,summary,payload_json,status,proposed_by,reviewed_by,review_note,created_at,reviewed_at,applied_at FROM data_change_proposals`); - await database.exec('DROP TABLE data_change_proposals'); - await database.exec('ALTER TABLE data_change_proposals_specializations RENAME TO data_change_proposals'); - await database.exec('PRAGMA foreign_keys=ON'); - await database.exec('CREATE INDEX IF NOT EXISTS data_change_proposals_status_created_idx ON data_change_proposals(status,created_at DESC)'); - await database.exec('CREATE INDEX IF NOT EXISTS data_change_proposals_module_status_idx ON data_change_proposals(module,status)'); - await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS data_change_proposals_draft_entity_idx ON data_change_proposals(draft_entity_id) WHERE draft_entity_id IS NOT NULL'); + await database.exec('DROP TABLE data_change_proposals'); + await database.exec( + 'ALTER TABLE data_change_proposals_specializations RENAME TO data_change_proposals', + ); + await database.exec('PRAGMA foreign_keys=ON'); + await database.exec( + 'CREATE INDEX IF NOT EXISTS data_change_proposals_status_created_idx ON data_change_proposals(status,created_at DESC)', + ); + await database.exec( + 'CREATE INDEX IF NOT EXISTS data_change_proposals_module_status_idx ON data_change_proposals(module,status)', + ); + await database.exec( + 'CREATE UNIQUE INDEX IF NOT EXISTS data_change_proposals_draft_entity_idx ON data_change_proposals(draft_entity_id) WHERE draft_entity_id IS NOT NULL', + ); } -const calculationProposalDefinition = await (await database.prepare( - "SELECT sql FROM sqlite_master WHERE type='table' AND name='data_change_proposals'" -)).get(); -if (typeof calculationProposalDefinition?.sql === 'string' && !calculationProposalDefinition.sql.includes("'calculations'")) { - console.log('Adding Combat Calculations to moderated data modules.'); - await database.exec('PRAGMA foreign_keys=OFF'); - await database.exec(`CREATE TABLE data_change_proposals_calculations ( +const calculationProposalDefinition = await ( + await database.prepare( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='data_change_proposals'", + ) +).get(); +if ( + typeof calculationProposalDefinition?.sql === 'string' && + !calculationProposalDefinition.sql.includes("'calculations'") +) { + console.log('Adding Combat Calculations to moderated data modules.'); + await database.exec('PRAGMA foreign_keys=OFF'); + await database.exec(`CREATE TABLE data_change_proposals_calculations ( id TEXT PRIMARY KEY, module TEXT NOT NULL CHECK(module IN ('configuration','calculations','attributes','talents','brands','gear','weapons','weaponmods','gearmods','protocols','skillchips','specializations')), action TEXT NOT NULL, @@ -1574,26 +2212,41 @@ if (typeof calculationProposalDefinition?.sql === 'string' && !calculationPropos reviewed_at TEXT, applied_at TEXT )`); - await database.exec(`INSERT INTO data_change_proposals_calculations + await database.exec(`INSERT INTO data_change_proposals_calculations SELECT id,module,action,entity_id,draft_entity_id,applied_entity_id,summary,payload_json,status,proposed_by,reviewed_by,review_note,created_at,reviewed_at,applied_at FROM data_change_proposals`); - await database.exec('DROP TABLE data_change_proposals'); - await database.exec('ALTER TABLE data_change_proposals_calculations RENAME TO data_change_proposals'); - await database.exec('PRAGMA foreign_keys=ON'); - await database.exec('CREATE INDEX IF NOT EXISTS data_change_proposals_status_created_idx ON data_change_proposals(status,created_at DESC)'); - await database.exec('CREATE INDEX IF NOT EXISTS data_change_proposals_module_status_idx ON data_change_proposals(module,status)'); - await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS data_change_proposals_draft_entity_idx ON data_change_proposals(draft_entity_id) WHERE draft_entity_id IS NOT NULL'); + await database.exec('DROP TABLE data_change_proposals'); + await database.exec( + 'ALTER TABLE data_change_proposals_calculations RENAME TO data_change_proposals', + ); + await database.exec('PRAGMA foreign_keys=ON'); + await database.exec( + 'CREATE INDEX IF NOT EXISTS data_change_proposals_status_created_idx ON data_change_proposals(status,created_at DESC)', + ); + await database.exec( + 'CREATE INDEX IF NOT EXISTS data_change_proposals_module_status_idx ON data_change_proposals(module,status)', + ); + await database.exec( + 'CREATE UNIQUE INDEX IF NOT EXISTS data_change_proposals_draft_entity_idx ON data_change_proposals(draft_entity_id) WHERE draft_entity_id IS NOT NULL', + ); } -const triggerProposalDefinition = await (await database.prepare( - "SELECT sql FROM sqlite_master WHERE type='table' AND name='data_change_proposals'" -)).get(); -if (typeof triggerProposalDefinition?.sql === 'string' && triggerProposalDefinition.sql.includes("'triggers'")) { - console.log('Retiring standalone Trigger proposals in favor of source-owned mechanic programs.'); - await database.exec('PRAGMA foreign_keys=OFF'); - await database.exec('BEGIN'); - try { - await database.exec(`CREATE TABLE data_change_proposals_source_owned ( +const triggerProposalDefinition = await ( + await database.prepare( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='data_change_proposals'", + ) +).get(); +if ( + typeof triggerProposalDefinition?.sql === 'string' && + triggerProposalDefinition.sql.includes("'triggers'") +) { + console.log( + 'Retiring standalone Trigger proposals in favor of source-owned mechanic programs.', + ); + await database.exec('PRAGMA foreign_keys=OFF'); + await database.exec('BEGIN'); + try { + await database.exec(`CREATE TABLE data_change_proposals_source_owned ( id TEXT PRIMARY KEY, module TEXT NOT NULL CHECK(module IN ('configuration','calculations','attributes','talents','brands','gear','weapons','weaponmods','gearmods','protocols','skillchips','specializations')), action TEXT NOT NULL, @@ -1610,7 +2263,7 @@ if (typeof triggerProposalDefinition?.sql === 'string' && triggerProposalDefinit reviewed_at TEXT, applied_at TEXT )`); - await database.exec(`INSERT INTO data_change_proposals_source_owned + await database.exec(`INSERT INTO data_change_proposals_source_owned SELECT id, CASE WHEN module='triggers' THEN 'talents' ELSE module END, CASE WHEN module='triggers' THEN 'legacyTrigger.'||action ELSE action END, @@ -1629,26 +2282,39 @@ if (typeof triggerProposalDefinition?.sql === 'string' && triggerProposalDefinit END, applied_at FROM data_change_proposals`); - await database.exec('DROP TABLE data_change_proposals'); - await database.exec('ALTER TABLE data_change_proposals_source_owned RENAME TO data_change_proposals'); - await database.exec('COMMIT'); - } catch (error) { - await database.exec('ROLLBACK'); - throw error; - } finally { - await database.exec('PRAGMA foreign_keys=ON'); - } - await database.exec('CREATE INDEX IF NOT EXISTS data_change_proposals_status_created_idx ON data_change_proposals(status,created_at DESC)'); - await database.exec('CREATE INDEX IF NOT EXISTS data_change_proposals_module_status_idx ON data_change_proposals(module,status)'); - await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS data_change_proposals_draft_entity_idx ON data_change_proposals(draft_entity_id) WHERE draft_entity_id IS NOT NULL'); + await database.exec('DROP TABLE data_change_proposals'); + await database.exec( + 'ALTER TABLE data_change_proposals_source_owned RENAME TO data_change_proposals', + ); + await database.exec('COMMIT'); + } catch (error) { + await database.exec('ROLLBACK'); + throw error; + } finally { + await database.exec('PRAGMA foreign_keys=ON'); + } + await database.exec( + 'CREATE INDEX IF NOT EXISTS data_change_proposals_status_created_idx ON data_change_proposals(status,created_at DESC)', + ); + await database.exec( + 'CREATE INDEX IF NOT EXISTS data_change_proposals_module_status_idx ON data_change_proposals(module,status)', + ); + await database.exec( + 'CREATE UNIQUE INDEX IF NOT EXISTS data_change_proposals_draft_entity_idx ON data_change_proposals(draft_entity_id) WHERE draft_entity_id IS NOT NULL', + ); } -const iconDefinition = await (await database.prepare( - "SELECT sql FROM sqlite_master WHERE type='table' AND name='game_data_icons'" -)).get(); -if (typeof iconDefinition?.sql === 'string' && !iconDefinition.sql.includes("'skill_chip'")) { - console.log('Adding Skill Chip icon storage support.'); - await database.exec(`CREATE TABLE game_data_icons_expanded ( +const iconDefinition = await ( + await database.prepare( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='game_data_icons'", + ) +).get(); +if ( + typeof iconDefinition?.sql === 'string' && + !iconDefinition.sql.includes("'skill_chip'") +) { + console.log('Adding Skill Chip icon storage support.'); + await database.exec(`CREATE TABLE game_data_icons_expanded ( entity_type TEXT NOT NULL CHECK(entity_type IN ('talent', 'brand_set', 'os_protocol', 'skill_chip')), entity_id TEXT NOT NULL, storage_key TEXT NOT NULL UNIQUE, @@ -1656,17 +2322,26 @@ if (typeof iconDefinition?.sql === 'string' && !iconDefinition.sql.includes("'sk updated_at TEXT NOT NULL, PRIMARY KEY(entity_type, entity_id) )`); - await database.exec(`INSERT INTO game_data_icons_expanded SELECT entity_type,entity_id,storage_key,byte_size,updated_at FROM game_data_icons`); - await database.exec('DROP TABLE game_data_icons'); - await database.exec('ALTER TABLE game_data_icons_expanded RENAME TO game_data_icons'); + await database.exec( + `INSERT INTO game_data_icons_expanded SELECT entity_type,entity_id,storage_key,byte_size,updated_at FROM game_data_icons`, + ); + await database.exec('DROP TABLE game_data_icons'); + await database.exec( + 'ALTER TABLE game_data_icons_expanded RENAME TO game_data_icons', + ); } -const specializationIconDefinition = await (await database.prepare( - "SELECT sql FROM sqlite_master WHERE type='table' AND name='game_data_icons'" -)).get(); -if (typeof specializationIconDefinition?.sql === 'string' && !specializationIconDefinition.sql.includes("'specialization_node'")) { - console.log('Adding Specialization icon storage support.'); - await database.exec(`CREATE TABLE game_data_icons_specializations ( +const specializationIconDefinition = await ( + await database.prepare( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='game_data_icons'", + ) +).get(); +if ( + typeof specializationIconDefinition?.sql === 'string' && + !specializationIconDefinition.sql.includes("'specialization_node'") +) { + console.log('Adding Specialization icon storage support.'); + await database.exec(`CREATE TABLE game_data_icons_specializations ( entity_type TEXT NOT NULL CHECK(entity_type IN ('talent', 'brand_set', 'os_protocol', 'skill_chip', 'specialization', 'specialization_ability', 'specialization_node')), entity_id TEXT NOT NULL, storage_key TEXT NOT NULL UNIQUE, @@ -1674,27 +2349,34 @@ if (typeof specializationIconDefinition?.sql === 'string' && !specializationIcon updated_at TEXT NOT NULL, PRIMARY KEY(entity_type, entity_id) )`); - await database.exec(`INSERT INTO game_data_icons_specializations + await database.exec(`INSERT INTO game_data_icons_specializations SELECT entity_type,entity_id,storage_key,byte_size,updated_at FROM game_data_icons`); - await database.exec('DROP TABLE game_data_icons'); - await database.exec('ALTER TABLE game_data_icons_specializations RENAME TO game_data_icons'); + await database.exec('DROP TABLE game_data_icons'); + await database.exec( + 'ALTER TABLE game_data_icons_specializations RENAME TO game_data_icons', + ); } -const managedImageColumns = await (await database.prepare("PRAGMA table_info('managed_images')")).all(); +const managedImageColumns = await ( + await database.prepare("PRAGMA table_info('managed_images')") +).all(); for (const [name, definition] of [ - ['source_entity_type', 'TEXT'], - ['source_entity_id', 'TEXT'] + ['source_entity_type', 'TEXT'], + ['source_entity_id', 'TEXT'], ]) { - if (!managedImageColumns.some((column) => column.name === name)) { - console.log(`Linking Image Library assets to ISAC entities: ${name}.`); - await database.exec(`ALTER TABLE managed_images ADD COLUMN ${name} ${definition}`); - } + if (!managedImageColumns.some((column) => column.name === name)) { + console.log(`Linking Image Library assets to ISAC entities: ${name}.`); + await database.exec( + `ALTER TABLE managed_images ADD COLUMN ${name} ${definition}`, + ); + } } await database.exec(`CREATE UNIQUE INDEX IF NOT EXISTS managed_images_source_entity_idx ON managed_images(source_entity_type,source_entity_id) WHERE source_entity_type IS NOT NULL AND source_entity_id IS NOT NULL`); -const iconLibraryCreator = await (await database.prepare(`SELECT users.id +const iconLibraryCreator = await ( + await database.prepare(`SELECT users.id FROM users LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id LEFT JOIN user_roles ON user_roles.user_id=users.id @@ -1705,148 +2387,260 @@ const iconLibraryCreator = await (await database.prepare(`SELECT users.id GROUP BY users.id,users.created_at ORDER BY MAX(CASE WHEN roles.name='Admin' THEN 1 WHEN permissions.name='*' THEN 1 ELSE 0 END) DESC, - users.created_at ASC LIMIT 1`)).get(); + users.created_at ASC LIMIT 1`) +).get(); const iconLibraryCategory = { - talent: 'Talents', - brand_set: 'Brands', - os_protocol: 'OS Protocols', - skill_chip: 'Skill Chips', - specialization: 'Specializations', - specialization_ability: 'Specializations', - specialization_node: 'Specializations' + talent: 'Talents', + brand_set: 'Brands', + os_protocol: 'OS Protocols', + skill_chip: 'Skill Chips', + specialization: 'Specializations', + specialization_ability: 'Specializations', + specialization_node: 'Specializations', }; const iconLibraryLabel = { - talent: 'Talent', - brand_set: 'Brand Set', - os_protocol: 'OS Protocol', - skill_chip: 'Skill Chip', - specialization: 'Specialization', - specialization_ability: 'Specialization Ability', - specialization_node: 'Specialization Tree Node' + talent: 'Talent', + brand_set: 'Brand Set', + os_protocol: 'OS Protocol', + skill_chip: 'Skill Chip', + specialization: 'Specialization', + specialization_ability: 'Specialization Ability', + specialization_node: 'Specialization Tree Node', }; -const normalizeLibraryTags = (values) => [...new Set(values - .flatMap((value) => String(value ?? '').split(',')) - .map((value) => value.normalize('NFKC').trim().toLocaleLowerCase()) - .filter(Boolean))].slice(0, 20); +const normalizeLibraryTags = (values) => + [ + ...new Set( + values + .flatMap((value) => String(value ?? '').split(',')) + .map((value) => value.normalize('NFKC').trim().toLocaleLowerCase()) + .filter(Boolean), + ), + ].slice(0, 20); const linkedIconMetadata = async (entityType, entityId) => { - switch (entityType) { - case 'talent': { - const row = await (await database.prepare(`SELECT talent.name,talent.category,scope.scope + switch (entityType) { + case 'talent': { + const row = await ( + await database.prepare(`SELECT talent.name,talent.category,scope.scope FROM talents talent LEFT JOIN talent_weapon_scopes scope ON scope.talent_id=talent.id - WHERE talent.id=?`)).get(entityId); - return { name: row?.name, tags: [row?.category === 'Vest' ? 'Body Armor' : row?.category, row?.scope] }; - } - case 'brand_set': { - const row = await (await database.prepare('SELECT name FROM brand_sets WHERE id=?')).get(entityId); - return { name: row?.name, tags: ['gear', 'set bonus'] }; - } - case 'os_protocol': { - const row = await (await database.prepare('SELECT name,quality,primary_attribute_type FROM os_protocols WHERE id=?')).get(entityId); - return { name: row?.name, tags: [row?.quality, row?.primary_attribute_type] }; - } - case 'skill_chip': { - const row = await (await database.prepare(`SELECT chip.name,ability.name AS ability_name,specialization.name AS specialization_name + WHERE talent.id=?`) + ).get(entityId); + return { + name: row?.name, + tags: [ + row?.category === 'Vest' ? 'Body Armor' : row?.category, + row?.scope, + ], + }; + } + case 'brand_set': { + const row = await ( + await database.prepare('SELECT name FROM brand_sets WHERE id=?') + ).get(entityId); + return { name: row?.name, tags: ['gear', 'set bonus'] }; + } + case 'os_protocol': { + const row = await ( + await database.prepare( + 'SELECT name,quality,primary_attribute_type FROM os_protocols WHERE id=?', + ) + ).get(entityId); + return { + name: row?.name, + tags: [row?.quality, row?.primary_attribute_type], + }; + } + case 'skill_chip': { + const row = await ( + await database.prepare(`SELECT chip.name,ability.name AS ability_name,specialization.name AS specialization_name FROM skill_chip_sets chip LEFT JOIN specialization_abilities ability ON ability.id=chip.specialization_ability_id LEFT JOIN specializations specialization ON specialization.id=ability.specialization_id - WHERE chip.id=?`)).get(entityId); - return { name: row?.name, tags: [row?.ability_name, row?.specialization_name] }; - } - case 'specialization': { - const row = await (await database.prepare('SELECT name FROM specializations WHERE id=?')).get(entityId); - return { name: row?.name, tags: ['class'] }; - } - case 'specialization_ability': { - const row = await (await database.prepare(`SELECT ability.name,specialization.name AS specialization_name + WHERE chip.id=?`) + ).get(entityId); + return { + name: row?.name, + tags: [row?.ability_name, row?.specialization_name], + }; + } + case 'specialization': { + const row = await ( + await database.prepare('SELECT name FROM specializations WHERE id=?') + ).get(entityId); + return { name: row?.name, tags: ['class'] }; + } + case 'specialization_ability': { + const row = await ( + await database.prepare(`SELECT ability.name,specialization.name AS specialization_name FROM specialization_abilities ability JOIN specializations specialization ON specialization.id=ability.specialization_id - WHERE ability.id=?`)).get(entityId); - return { name: row?.name, tags: [row?.specialization_name, 'ability'] }; - } - case 'specialization_node': { - const row = await (await database.prepare(`SELECT node.name,node.row_number,focus.name AS focus_name, + WHERE ability.id=?`) + ).get(entityId); + return { name: row?.name, tags: [row?.specialization_name, 'ability'] }; + } + case 'specialization_node': { + const row = await ( + await database.prepare(`SELECT node.name,node.row_number,focus.name AS focus_name, specialization.name AS specialization_name FROM specialization_tree_nodes node JOIN specialization_focuses focus ON focus.id=node.focus_id JOIN specializations specialization ON specialization.id=focus.specialization_id - WHERE node.id=?`)).get(entityId); - return { name: row?.name, tags: [row?.specialization_name, row?.focus_name, `row ${row?.row_number}`, 'tree node'] }; - } - default: - return { name: null, tags: [] }; - } + WHERE node.id=?`) + ).get(entityId); + return { + name: row?.name, + tags: [ + row?.specialization_name, + row?.focus_name, + `row ${row?.row_number}`, + 'tree node', + ], + }; + } + default: + return { name: null, tags: [] }; + } }; const uniqueLibraryName = async (preferred, entityType, entityId) => { - const candidates = [ - preferred, - `${preferred} · ${iconLibraryLabel[entityType] ?? 'ISAC Asset'}`, - `${preferred} · ${entityId.slice(0, 8)}` - ]; - for (const candidate of candidates) { - const normalized = candidate.slice(0, 80); - const conflict = await (await database.prepare('SELECT id FROM managed_images WHERE name=? COLLATE NOCASE')).get(normalized); - if (!conflict) return normalized; - } - return `${preferred.slice(0, 64)} · ${entityId.slice(0, 8)}`; + const candidates = [ + preferred, + `${preferred} · ${iconLibraryLabel[entityType] ?? 'ISAC Asset'}`, + `${preferred} · ${entityId.slice(0, 8)}`, + ]; + for (const candidate of candidates) { + const normalized = candidate.slice(0, 80); + const conflict = await ( + await database.prepare( + 'SELECT id FROM managed_images WHERE name=? COLLATE NOCASE', + ) + ).get(normalized); + if (!conflict) return normalized; + } + return `${preferred.slice(0, 64)} · ${entityId.slice(0, 8)}`; }; if (iconLibraryCreator?.id) { - const unregisteredIcons = await (await database.prepare(`SELECT icon.* + const unregisteredIcons = await ( + await database.prepare(`SELECT icon.* FROM game_data_icons icon LEFT JOIN managed_images image ON image.source_entity_type=icon.entity_type AND image.source_entity_id=icon.entity_id WHERE image.id IS NULL - ORDER BY icon.entity_type,icon.entity_id`)).all(); - if (unregisteredIcons.length) console.log(`Registering ${unregisteredIcons.length} existing ISAC icons in the Image Library.`); - for (const icon of unregisteredIcons) { - const metadata = await linkedIconMetadata(String(icon.entity_type), String(icon.entity_id)); - const label = iconLibraryLabel[icon.entity_type] ?? 'ISAC Asset'; - const preferredName = String(metadata.name ?? `${label} ${String(icon.entity_id).slice(0, 8)}`); - const name = await uniqueLibraryName(preferredName, String(icon.entity_type), String(icon.entity_id)); - const tags = normalizeLibraryTags([ - 'isac', 'game data', String(icon.entity_type).replaceAll('_', ' '), preferredName, ...metadata.tags - ]); - const now = String(icon.updated_at ?? new Date().toISOString()); - await (await database.prepare(`INSERT INTO managed_images + ORDER BY icon.entity_type,icon.entity_id`) + ).all(); + if (unregisteredIcons.length) + console.log( + `Registering ${unregisteredIcons.length} existing ISAC icons in the Image Library.`, + ); + for (const icon of unregisteredIcons) { + const metadata = await linkedIconMetadata( + String(icon.entity_type), + String(icon.entity_id), + ); + const label = iconLibraryLabel[icon.entity_type] ?? 'ISAC Asset'; + const preferredName = String( + metadata.name ?? `${label} ${String(icon.entity_id).slice(0, 8)}`, + ); + const name = await uniqueLibraryName( + preferredName, + String(icon.entity_type), + String(icon.entity_id), + ); + const tags = normalizeLibraryTags([ + 'isac', + 'game data', + String(icon.entity_type).replaceAll('_', ' '), + preferredName, + ...metadata.tags, + ]); + const now = String(icon.updated_at ?? new Date().toISOString()); + await ( + await database.prepare(`INSERT INTO managed_images (id,name,category,tags_json,is_icon,force_white,storage_key,original_filename,byte_size,width,height, source_entity_type,source_entity_id,created_by,created_at,updated_at) VALUES(lower(hex(randomblob(4)))||'-'||lower(hex(randomblob(2)))||'-4'||substr(lower(hex(randomblob(2))),2) ||'-'||substr('89ab',abs(random())%4+1,1)||substr(lower(hex(randomblob(2))),2)||'-'||lower(hex(randomblob(6))), - ?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run( - name, iconLibraryCategory[icon.entity_type] ?? 'General', JSON.stringify(tags), 1, 0, - icon.storage_key, icon.storage_key, icon.byte_size, 256, 256, - icon.entity_type, icon.entity_id, iconLibraryCreator.id, now, now - ); - } - const linkedIcons = await (await database.prepare(`SELECT icon.*,image.id AS library_image_id,image.tags_json + ?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`) + ).run( + name, + iconLibraryCategory[icon.entity_type] ?? 'General', + JSON.stringify(tags), + 1, + 0, + icon.storage_key, + icon.storage_key, + icon.byte_size, + 256, + 256, + icon.entity_type, + icon.entity_id, + iconLibraryCreator.id, + now, + now, + ); + } + const linkedIcons = await ( + await database.prepare(`SELECT icon.*,image.id AS library_image_id,image.tags_json FROM game_data_icons icon JOIN managed_images image ON image.source_entity_type=icon.entity_type AND image.source_entity_id=icon.entity_id - ORDER BY icon.entity_type,icon.entity_id`)).all(); - for (const icon of linkedIcons) { - const metadata = await linkedIconMetadata(String(icon.entity_type), String(icon.entity_id)); - const preferredName = String(metadata.name ?? `${iconLibraryLabel[icon.entity_type] ?? 'ISAC Asset'} ${String(icon.entity_id).slice(0, 8)}`); - let existingTags = []; - try { existingTags = JSON.parse(String(icon.tags_json)); } catch { /* replace malformed legacy tags below */ } - existingTags = existingTags.filter((tag) => String(tag).toLocaleLowerCase() !== 'vest'); - const tags = normalizeLibraryTags([ - ...existingTags, 'isac', 'game data', String(icon.entity_type).replaceAll('_', ' '), preferredName, ...metadata.tags - ]); - await (await database.prepare(`UPDATE managed_images SET + ORDER BY icon.entity_type,icon.entity_id`) + ).all(); + for (const icon of linkedIcons) { + const metadata = await linkedIconMetadata( + String(icon.entity_type), + String(icon.entity_id), + ); + const preferredName = String( + metadata.name ?? + `${iconLibraryLabel[icon.entity_type] ?? 'ISAC Asset'} ${String(icon.entity_id).slice(0, 8)}`, + ); + let existingTags = []; + try { + existingTags = JSON.parse(String(icon.tags_json)); + } catch { + /* replace malformed legacy tags below */ + } + existingTags = existingTags.filter( + (tag) => String(tag).toLocaleLowerCase() !== 'vest', + ); + const tags = normalizeLibraryTags([ + ...existingTags, + 'isac', + 'game data', + String(icon.entity_type).replaceAll('_', ' '), + preferredName, + ...metadata.tags, + ]); + await ( + await database.prepare(`UPDATE managed_images SET category=?,tags_json=?,storage_key=?,original_filename=?,byte_size=?,width=256,height=256 - WHERE id=?`)).run( - iconLibraryCategory[icon.entity_type] ?? 'General', JSON.stringify(tags), - icon.storage_key, icon.storage_key, icon.byte_size, icon.library_image_id - ); - } + WHERE id=?`) + ).run( + iconLibraryCategory[icon.entity_type] ?? 'General', + JSON.stringify(tags), + icon.storage_key, + icon.storage_key, + icon.byte_size, + icon.library_image_id, + ); + } } -const gearAugmentColumns = await (await database.prepare("PRAGMA table_info('gear_tactical_augments')")).all(); -if (gearAugmentColumns.length && !gearAugmentColumns.some((column) => column.name === 'effect_type')) { - console.log('Separating custom Gear Tactical Augments from fixed base-item percentage upgrades.'); - await database.exec('ALTER TABLE gear_tactical_augments RENAME TO gear_tactical_augments_legacy'); - await database.exec(`CREATE TABLE gear_tactical_augments ( +const gearAugmentColumns = await ( + await database.prepare("PRAGMA table_info('gear_tactical_augments')") +).all(); +if ( + gearAugmentColumns.length && + !gearAugmentColumns.some((column) => column.name === 'effect_type') +) { + console.log( + 'Separating custom Gear Tactical Augments from fixed base-item percentage upgrades.', + ); + await database.exec( + 'ALTER TABLE gear_tactical_augments RENAME TO gear_tactical_augments_legacy', + ); + await database.exec(`CREATE TABLE gear_tactical_augments ( id TEXT PRIMARY KEY, gear_slot TEXT NOT NULL CHECK(gear_slot IN ('Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask')), augment_slot INTEGER NOT NULL CHECK(augment_slot BETWEEN 1 AND 4), @@ -1860,7 +2654,7 @@ if (gearAugmentColumns.length && !gearAugmentColumns.some((column) => column.nam CHECK((gear_slot IN ('Backpack', 'Body Armor') AND augment_slot BETWEEN 1 AND 4) OR (gear_slot NOT IN ('Backpack', 'Body Armor') AND augment_slot BETWEEN 1 AND 3)), CHECK((augment_slot = 1 AND effect_type = 'Custom' AND stat_attribute_id IS NOT NULL) OR (augment_slot > 1 AND effect_type = 'Base Percentage' AND stat_attribute_id IS NULL AND value > 0)) )`); - await database.exec(`INSERT INTO gear_tactical_augments + await database.exec(`INSERT INTO gear_tactical_augments (id,gear_slot,augment_slot,effect_type,stat_attribute_id,value,unlock_cost,created_at,updated_at) SELECT id,gear_slot,augment_slot, CASE WHEN augment_slot=1 THEN 'Custom' ELSE 'Base Percentage' END, @@ -1875,15 +2669,19 @@ if (gearAugmentColumns.length && !gearAugmentColumns.some((column) => column.nam END, unlock_cost,created_at,updated_at FROM gear_tactical_augments_legacy`); - await database.exec('DROP TABLE gear_tactical_augments_legacy'); + await database.exec('DROP TABLE gear_tactical_augments_legacy'); } -const tieredGearAugmentColumns = await (await database.prepare("PRAGMA table_info('gear_tactical_augments')")).all(); +const tieredGearAugmentColumns = await ( + await database.prepare("PRAGMA table_info('gear_tactical_augments')") +).all(); if (tieredGearAugmentColumns.some((column) => column.name === 'unlock_cost')) { - console.log('Separating Gear Tactical Augment unlock costs by item tier.'); - await database.exec('DROP TABLE IF EXISTS gear_tactical_augment_costs'); - await database.exec('ALTER TABLE gear_tactical_augments RENAME TO gear_tactical_augments_single_cost'); - await database.exec(`CREATE TABLE gear_tactical_augments ( + console.log('Separating Gear Tactical Augment unlock costs by item tier.'); + await database.exec('DROP TABLE IF EXISTS gear_tactical_augment_costs'); + await database.exec( + 'ALTER TABLE gear_tactical_augments RENAME TO gear_tactical_augments_single_cost', + ); + await database.exec(`CREATE TABLE gear_tactical_augments ( id TEXT PRIMARY KEY, gear_slot TEXT NOT NULL CHECK(gear_slot IN ('Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask')), augment_slot INTEGER NOT NULL CHECK(augment_slot BETWEEN 1 AND 4), @@ -1896,104 +2694,151 @@ if (tieredGearAugmentColumns.some((column) => column.name === 'unlock_cost')) { CHECK((gear_slot IN ('Backpack', 'Body Armor') AND augment_slot BETWEEN 1 AND 4) OR (gear_slot NOT IN ('Backpack', 'Body Armor') AND augment_slot BETWEEN 1 AND 3)), CHECK((augment_slot = 1 AND effect_type = 'Custom' AND stat_attribute_id IS NOT NULL) OR (augment_slot > 1 AND effect_type = 'Base Percentage' AND stat_attribute_id IS NULL AND value > 0)) )`); - await database.exec(`INSERT INTO gear_tactical_augments + await database.exec(`INSERT INTO gear_tactical_augments (id,gear_slot,augment_slot,effect_type,stat_attribute_id,value,created_at,updated_at) SELECT id,gear_slot,augment_slot,effect_type,stat_attribute_id,value,created_at,updated_at FROM gear_tactical_augments_single_cost`); - await database.exec(`CREATE TABLE gear_tactical_augment_costs ( + await database.exec(`CREATE TABLE gear_tactical_augment_costs ( augment_id TEXT NOT NULL REFERENCES gear_tactical_augments(id) ON DELETE CASCADE, gear_tier INTEGER NOT NULL CHECK(gear_tier BETWEEN 0 AND 2), unlock_cost INTEGER NOT NULL CHECK(unlock_cost >= 0), PRIMARY KEY(augment_id, gear_tier) )`); - await database.exec(`INSERT INTO gear_tactical_augment_costs(augment_id,gear_tier,unlock_cost) + await database.exec(`INSERT INTO gear_tactical_augment_costs(augment_id,gear_tier,unlock_cost) SELECT id,0,unlock_cost FROM gear_tactical_augments_single_cost UNION ALL SELECT id,1,unlock_cost FROM gear_tactical_augments_single_cost UNION ALL SELECT id,2,unlock_cost FROM gear_tactical_augments_single_cost`); - await database.exec('DROP TABLE gear_tactical_augments_single_cost'); + await database.exec('DROP TABLE gear_tactical_augments_single_cost'); } -const gearColumns = await (await database.prepare("PRAGMA table_info('gear')")).all(); +const gearColumns = await ( + await database.prepare("PRAGMA table_info('gear')") +).all(); if (gearColumns.some((column) => column.name === 'level')) { - console.log('Moving Gear level from canonical ISAC data to builder-instance state.'); - await database.exec('ALTER TABLE gear DROP COLUMN level'); + console.log( + 'Moving Gear level from canonical ISAC data to builder-instance state.', + ); + await database.exec('ALTER TABLE gear DROP COLUMN level'); } if (!gearColumns.some((column) => column.name === 'range_reference_level')) { - console.log('Recording the reference level for canonical Gear ranges.'); - await database.exec('ALTER TABLE gear ADD COLUMN range_reference_level INTEGER NOT NULL DEFAULT 50 CHECK(range_reference_level BETWEEN 1 AND 50)'); + console.log('Recording the reference level for canonical Gear ranges.'); + await database.exec( + 'ALTER TABLE gear ADD COLUMN range_reference_level INTEGER NOT NULL DEFAULT 50 CHECK(range_reference_level BETWEEN 1 AND 50)', + ); } -const weaponRangeColumns = await (await database.prepare("PRAGMA table_info('weapon_tier_ranges')")).all(); +const weaponRangeColumns = await ( + await database.prepare("PRAGMA table_info('weapon_tier_ranges')") +).all(); if (!weaponRangeColumns.some((column) => column.name === 'reference_level')) { - console.log('Recording the reference level for canonical Weapon ranges.'); - await database.exec('ALTER TABLE weapon_tier_ranges ADD COLUMN reference_level INTEGER NOT NULL DEFAULT 50 CHECK(reference_level BETWEEN 1 AND 50)'); + console.log('Recording the reference level for canonical Weapon ranges.'); + await database.exec( + 'ALTER TABLE weapon_tier_ranges ADD COLUMN reference_level INTEGER NOT NULL DEFAULT 50 CHECK(reference_level BETWEEN 1 AND 50)', + ); } -const attributeColumns = await (await database.prepare("PRAGMA table_info('stat_attributes')")).all(); +const attributeColumns = await ( + await database.prepare("PRAGMA table_info('stat_attributes')") +).all(); for (const [name, definition] of [ - ['description', 'TEXT'], - ['gear_reference_level', 'INTEGER NOT NULL DEFAULT 50 CHECK(gear_reference_level BETWEEN 1 AND 50)'], - ['weapon_reference_level', 'INTEGER NOT NULL DEFAULT 50 CHECK(weapon_reference_level BETWEEN 1 AND 50)'] + ['description', 'TEXT'], + [ + 'gear_reference_level', + 'INTEGER NOT NULL DEFAULT 50 CHECK(gear_reference_level BETWEEN 1 AND 50)', + ], + [ + 'weapon_reference_level', + 'INTEGER NOT NULL DEFAULT 50 CHECK(weapon_reference_level BETWEEN 1 AND 50)', + ], ]) { - if (!attributeColumns.some((column) => column.name === name)) { - console.log(name === 'description' - ? 'Adding optional Attribute descriptions.' - : `Recording the reference level for Attribute source ranges: ${name}.`); - await database.exec(`ALTER TABLE stat_attributes ADD COLUMN ${name} ${definition}`); - } + if (!attributeColumns.some((column) => column.name === name)) { + console.log( + name === 'description' + ? 'Adding optional Attribute descriptions.' + : `Recording the reference level for Attribute source ranges: ${name}.`, + ); + await database.exec( + `ALTER TABLE stat_attributes ADD COLUMN ${name} ${definition}`, + ); + } } -const nameOnlyAttributeIndex = await (await database.prepare( - "SELECT 1 AS present FROM sqlite_master WHERE type='index' AND name='stat_attributes_name_nocase_idx'" -)).get(); +const nameOnlyAttributeIndex = await ( + await database.prepare( + "SELECT 1 AS present FROM sqlite_master WHERE type='index' AND name='stat_attributes_name_nocase_idx'", + ) +).get(); if (nameOnlyAttributeIndex) { - console.log('Allowing Attribute names to repeat when their units differ.'); - await database.exec('DROP INDEX stat_attributes_name_nocase_idx'); + console.log('Allowing Attribute names to repeat when their units differ.'); + await database.exec('DROP INDEX stat_attributes_name_nocase_idx'); } for (const [previousName, currentName] of [ - ['Engineering %', 'Engineering'], - ['Firepower %', 'Firepower'], - ['Toughness %', 'Toughness'], - ['Weapon Damage %', 'Weapon Damage'] + ['Engineering %', 'Engineering'], + ['Firepower %', 'Firepower'], + ['Toughness %', 'Toughness'], + ['Weapon Damage %', 'Weapon Damage'], ]) { - const previous = await (await database.prepare( - 'SELECT id FROM stat_attributes WHERE name=? COLLATE NOCASE AND unit=?' - )).get(previousName, '%'); - const current = await (await database.prepare( - 'SELECT id FROM stat_attributes WHERE name=? COLLATE NOCASE AND unit=?' - )).get(currentName, '%'); - if (previous && !current) { - await (await database.prepare('UPDATE stat_attributes SET name=?,updated_at=? WHERE id=?')) - .run(currentName, new Date().toISOString(), previous.id); - } + const previous = await ( + await database.prepare( + 'SELECT id FROM stat_attributes WHERE name=? COLLATE NOCASE AND unit=?', + ) + ).get(previousName, '%'); + const current = await ( + await database.prepare( + 'SELECT id FROM stat_attributes WHERE name=? COLLATE NOCASE AND unit=?', + ) + ).get(currentName, '%'); + if (previous && !current) { + await ( + await database.prepare( + 'UPDATE stat_attributes SET name=?,updated_at=? WHERE id=?', + ) + ).run(currentName, new Date().toISOString(), previous.id); + } } -await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS stat_attributes_name_unit_nocase_idx ON stat_attributes(name COLLATE NOCASE,unit)'); +await database.exec( + 'CREATE UNIQUE INDEX IF NOT EXISTS stat_attributes_name_unit_nocase_idx ON stat_attributes(name COLLATE NOCASE,unit)', +); -const talentColumns = await (await database.prepare("PRAGMA table_info('talents')")).all(); +const talentColumns = await ( + await database.prepare("PRAGMA table_info('talents')") +).all(); for (const [name, definition] of [ - ['cooldown_seconds', 'REAL CHECK(cooldown_seconds IS NULL OR cooldown_seconds > 0)'], - ['duration_seconds', 'REAL CHECK(duration_seconds IS NULL OR duration_seconds > 0)'], - ['max_stacks', 'INTEGER CHECK(max_stacks IS NULL OR max_stacks > 0)'] + [ + 'cooldown_seconds', + 'REAL CHECK(cooldown_seconds IS NULL OR cooldown_seconds > 0)', + ], + [ + 'duration_seconds', + 'REAL CHECK(duration_seconds IS NULL OR duration_seconds > 0)', + ], + ['max_stacks', 'INTEGER CHECK(max_stacks IS NULL OR max_stacks > 0)'], ]) { - if (!talentColumns.some((column) => column.name === name)) { - console.log(`Adding optional Talent mechanic: ${name}.`); - await database.exec(`ALTER TABLE talents ADD COLUMN ${name} ${definition}`); - } + if (!talentColumns.some((column) => column.name === name)) { + console.log(`Adding optional Talent mechanic: ${name}.`); + await database.exec(`ALTER TABLE talents ADD COLUMN ${name} ${definition}`); + } } -const weaponModColumns = await (await database.prepare("PRAGMA table_info('weapon_mods')")).all(); -if (weaponModColumns.some((column) => column.name === 'primary_attribute_type')) { - console.log('Converting Weapon Mods to builder-selected primary types and pooled secondary attributes.'); - await database.exec('PRAGMA foreign_keys=OFF'); - await database.exec(`INSERT OR IGNORE INTO weapon_mod_secondary_options(weapon_mod_id,stat_attribute_id) +const weaponModColumns = await ( + await database.prepare("PRAGMA table_info('weapon_mods')") +).all(); +if ( + weaponModColumns.some((column) => column.name === 'primary_attribute_type') +) { + console.log( + 'Converting Weapon Mods to builder-selected primary types and pooled secondary attributes.', + ); + await database.exec('PRAGMA foreign_keys=OFF'); + await database.exec(`INSERT OR IGNORE INTO weapon_mod_secondary_options(weapon_mod_id,stat_attribute_id) SELECT id,secondary_stat_1_id FROM weapon_mods UNION SELECT id,secondary_stat_2_id FROM weapon_mods`); - await database.exec(`INSERT OR IGNORE INTO weapon_mod_secondary_quality_values(weapon_mod_id,stat_attribute_id,quality,min_value,max_value) + await database.exec(`INSERT OR IGNORE INTO weapon_mod_secondary_quality_values(weapon_mod_id,stat_attribute_id,quality,min_value,max_value) SELECT values_row.weapon_mod_id,mods.secondary_stat_1_id,values_row.quality,values_row.secondary_1_min,values_row.secondary_1_max FROM weapon_mod_quality_values values_row JOIN weapon_mods mods ON mods.id=values_row.weapon_mod_id UNION ALL SELECT values_row.weapon_mod_id,mods.secondary_stat_2_id,values_row.quality,values_row.secondary_2_min,values_row.secondary_2_max FROM weapon_mod_quality_values values_row JOIN weapon_mods mods ON mods.id=values_row.weapon_mod_id`); - await database.exec(`CREATE TABLE weapon_mod_quality_values_pooled ( + await database.exec(`CREATE TABLE weapon_mod_quality_values_pooled ( weapon_mod_id TEXT NOT NULL REFERENCES weapon_mods(id) ON DELETE CASCADE, quality TEXT NOT NULL CHECK(quality IN ('Standard','Specialized','Superior','High-end')), primary_level_1_min REAL NOT NULL, @@ -2006,12 +2851,14 @@ if (weaponModColumns.some((column) => column.name === 'primary_attribute_type')) CHECK(primary_level_20_min>=primary_level_1_min), CHECK(primary_level_20_max>=primary_level_1_max) )`); - await database.exec(`INSERT INTO weapon_mod_quality_values_pooled + await database.exec(`INSERT INTO weapon_mod_quality_values_pooled SELECT weapon_mod_id,quality,primary_level_1_min,primary_level_1_max,primary_level_20_min,primary_level_20_max FROM weapon_mod_quality_values`); - await database.exec('DROP TABLE weapon_mod_quality_values'); - await database.exec('ALTER TABLE weapon_mod_quality_values_pooled RENAME TO weapon_mod_quality_values'); - await database.exec(`CREATE TABLE weapon_mods_pooled ( + await database.exec('DROP TABLE weapon_mod_quality_values'); + await database.exec( + 'ALTER TABLE weapon_mod_quality_values_pooled RENAME TO weapon_mod_quality_values', + ); + await database.exec(`CREATE TABLE weapon_mods_pooled ( id TEXT PRIMARY KEY, name TEXT NOT NULL, slot_type TEXT NOT NULL CHECK(slot_type IN ('Optics','Muzzle','Underbarrel','Magazine','Barrel','Upper Receiver','Lower Receiver','Trigger')), @@ -2020,49 +2867,72 @@ if (weaponModColumns.some((column) => column.name === 'primary_attribute_type')) created_at TEXT NOT NULL, updated_at TEXT NOT NULL )`); - await database.exec(`INSERT INTO weapon_mods_pooled(id,name,slot_type,exotic_weapon_id,active,created_at,updated_at) + await database.exec(`INSERT INTO weapon_mods_pooled(id,name,slot_type,exotic_weapon_id,active,created_at,updated_at) SELECT id,name,slot_type,exotic_weapon_id,active,created_at,updated_at FROM weapon_mods`); - await database.exec('DROP TABLE weapon_mods'); - await database.exec('ALTER TABLE weapon_mods_pooled RENAME TO weapon_mods'); - await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS weapon_mods_name_nocase_idx ON weapon_mods(name COLLATE NOCASE)'); - await database.exec('PRAGMA foreign_keys=ON'); + await database.exec('DROP TABLE weapon_mods'); + await database.exec('ALTER TABLE weapon_mods_pooled RENAME TO weapon_mods'); + await database.exec( + 'CREATE UNIQUE INDEX IF NOT EXISTS weapon_mods_name_nocase_idx ON weapon_mods(name COLLATE NOCASE)', + ); + await database.exec('PRAGMA foreign_keys=ON'); } -const buildColumns = await (await database.prepare("PRAGMA table_info('builds')")).all(); -if (buildColumns.length && !buildColumns.some((column) => column.name === 'loadout_json')) { - console.log('Adding persistent builder loadout snapshots.'); - await database.exec('ALTER TABLE builds ADD COLUMN loadout_json TEXT'); +const buildColumns = await ( + await database.prepare("PRAGMA table_info('builds')") +).all(); +if ( + buildColumns.length && + !buildColumns.some((column) => column.name === 'loadout_json') +) { + console.log('Adding persistent builder loadout snapshots.'); + await database.exec('ALTER TABLE builds ADD COLUMN loadout_json TEXT'); } if (buildColumns.length) { - await database.exec("UPDATE builds SET visibility='private' WHERE visibility='draft'"); - const builds = await (await database.prepare('SELECT id,loadout_json FROM builds')).all(); - const obsoleteBuildIds = builds - .filter((build) => !isCurrentLoadoutSnapshot(build.loadout_json)) - .map((build) => String(build.id)); - if (obsoleteBuildIds.length) { - console.log(`Removing ${obsoleteBuildIds.length} build record(s) that do not use the current loadout format.`); - await database.exec('BEGIN'); - try { - for (const id of obsoleteBuildIds) { - await (await database.prepare('UPDATE builds SET source_build_id=NULL WHERE source_build_id=?')).run(id); - await (await database.prepare('DELETE FROM builds WHERE id=?')).run(id); - } - await database.exec('COMMIT'); - } catch (error) { - await database.exec('ROLLBACK'); - throw error; - } - } - const currentBuildColumns = await (await database.prepare("PRAGMA table_info('builds')")).all(); - const loadoutColumn = currentBuildColumns.find((column) => column.name === 'loadout_json'); - if (loadoutColumn && Number(loadoutColumn.notnull) !== 1) { - console.log('Requiring every build record to contain a current loadout snapshot.'); - await database.exec('DROP INDEX IF EXISTS builds_visibility_updated_idx'); - await database.exec('PRAGMA foreign_keys=OFF'); - await database.exec('BEGIN'); - try { - await database.exec('DROP TABLE IF EXISTS builds_current'); - await database.exec(`CREATE TABLE builds_current ( + await database.exec( + "UPDATE builds SET visibility='private' WHERE visibility='draft'", + ); + const builds = await ( + await database.prepare('SELECT id,loadout_json FROM builds') + ).all(); + const obsoleteBuildIds = builds + .filter((build) => !isCurrentLoadoutSnapshot(build.loadout_json)) + .map((build) => String(build.id)); + if (obsoleteBuildIds.length) { + console.log( + `Removing ${obsoleteBuildIds.length} build record(s) that do not use the current loadout format.`, + ); + await database.exec('BEGIN'); + try { + for (const id of obsoleteBuildIds) { + await ( + await database.prepare( + 'UPDATE builds SET source_build_id=NULL WHERE source_build_id=?', + ) + ).run(id); + await (await database.prepare('DELETE FROM builds WHERE id=?')).run(id); + } + await database.exec('COMMIT'); + } catch (error) { + await database.exec('ROLLBACK'); + throw error; + } + } + const currentBuildColumns = await ( + await database.prepare("PRAGMA table_info('builds')") + ).all(); + const loadoutColumn = currentBuildColumns.find( + (column) => column.name === 'loadout_json', + ); + if (loadoutColumn && Number(loadoutColumn.notnull) !== 1) { + console.log( + 'Requiring every build record to contain a current loadout snapshot.', + ); + await database.exec('DROP INDEX IF EXISTS builds_visibility_updated_idx'); + await database.exec('PRAGMA foreign_keys=OFF'); + await database.exec('BEGIN'); + try { + await database.exec('DROP TABLE IF EXISTS builds_current'); + await database.exec(`CREATE TABLE builds_current ( id TEXT PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES users(id), title TEXT NOT NULL, @@ -2075,46 +2945,71 @@ if (buildColumns.length) { created_at TEXT NOT NULL, updated_at TEXT NOT NULL )`); - await database.exec(`INSERT INTO builds_current(id,owner_id,title,slug,visibility,summary,guide_body,source_build_id,loadout_json,created_at,updated_at) + await database.exec(`INSERT INTO builds_current(id,owner_id,title,slug,visibility,summary,guide_body,source_build_id,loadout_json,created_at,updated_at) SELECT id,owner_id,title,slug,visibility,summary,guide_body,source_build_id,loadout_json,created_at,updated_at FROM builds`); - await database.exec('DROP TABLE builds'); - await database.exec('ALTER TABLE builds_current RENAME TO builds'); - await database.exec('COMMIT'); - } catch (error) { - await database.exec('ROLLBACK'); - throw error; - } finally { - await database.exec('PRAGMA foreign_keys=ON'); - } - } + await database.exec('DROP TABLE builds'); + await database.exec('ALTER TABLE builds_current RENAME TO builds'); + await database.exec('COMMIT'); + } catch (error) { + await database.exec('ROLLBACK'); + throw error; + } finally { + await database.exec('PRAGMA foreign_keys=ON'); + } + } } -await database.exec('CREATE INDEX IF NOT EXISTS builds_visibility_updated_idx ON builds(visibility,updated_at DESC)'); +await database.exec( + 'CREATE INDEX IF NOT EXISTS builds_visibility_updated_idx ON builds(visibility,updated_at DESC)', +); -const skillChipColumns = await (await database.prepare("PRAGMA table_info('skill_chip_sets')")).all(); -if (skillChipColumns.length && !skillChipColumns.some((column) => column.name === 'specialization_ability_id')) { - console.log('Associating Skill Chip definitions with specialization abilities.'); - await database.exec('ALTER TABLE skill_chip_sets ADD COLUMN specialization_ability_id TEXT REFERENCES specialization_abilities(id)'); +const skillChipColumns = await ( + await database.prepare("PRAGMA table_info('skill_chip_sets')") +).all(); +if ( + skillChipColumns.length && + !skillChipColumns.some( + (column) => column.name === 'specialization_ability_id', + ) +) { + console.log( + 'Associating Skill Chip definitions with specialization abilities.', + ); + await database.exec( + 'ALTER TABLE skill_chip_sets ADD COLUMN specialization_ability_id TEXT REFERENCES specialization_abilities(id)', + ); } -const currentSkillChipColumns = await (await database.prepare("PRAGMA table_info('skill_chip_sets')")).all(); -const skillChipAbilityColumn = currentSkillChipColumns.find((column) => column.name === 'specialization_ability_id'); +const currentSkillChipColumns = await ( + await database.prepare("PRAGMA table_info('skill_chip_sets')") +).all(); +const skillChipAbilityColumn = currentSkillChipColumns.find( + (column) => column.name === 'specialization_ability_id', +); if (skillChipAbilityColumn && Number(skillChipAbilityColumn.notnull) !== 1) { - const unassignedSkillChips = await (await database.prepare( - 'SELECT id FROM skill_chip_sets WHERE specialization_ability_id IS NULL' - )).all(); - if (unassignedSkillChips.length) { - console.log(`Removing ${unassignedSkillChips.length} Skill Chip record(s) without a specialization ability.`); - for (const row of unassignedSkillChips) { - await (await database.prepare('DELETE FROM skill_chip_sets WHERE id=?')).run(row.id); - } - } - console.log('Requiring every Skill Chip definition to reference a specialization ability.'); - await database.exec('DROP INDEX IF EXISTS skill_chip_sets_ability_idx'); - await database.exec('DROP INDEX IF EXISTS skill_chip_sets_name_nocase_idx'); - await database.exec('PRAGMA foreign_keys=OFF'); - await database.exec('BEGIN'); - try { - await database.exec('DROP TABLE IF EXISTS skill_chip_sets_current'); - await database.exec(`CREATE TABLE skill_chip_sets_current ( + const unassignedSkillChips = await ( + await database.prepare( + 'SELECT id FROM skill_chip_sets WHERE specialization_ability_id IS NULL', + ) + ).all(); + if (unassignedSkillChips.length) { + console.log( + `Removing ${unassignedSkillChips.length} Skill Chip record(s) without a specialization ability.`, + ); + for (const row of unassignedSkillChips) { + await ( + await database.prepare('DELETE FROM skill_chip_sets WHERE id=?') + ).run(row.id); + } + } + console.log( + 'Requiring every Skill Chip definition to reference a specialization ability.', + ); + await database.exec('DROP INDEX IF EXISTS skill_chip_sets_ability_idx'); + await database.exec('DROP INDEX IF EXISTS skill_chip_sets_name_nocase_idx'); + await database.exec('PRAGMA foreign_keys=OFF'); + await database.exec('BEGIN'); + try { + await database.exec('DROP TABLE IF EXISTS skill_chip_sets_current'); + await database.exec(`CREATE TABLE skill_chip_sets_current ( id TEXT PRIMARY KEY, name TEXT NOT NULL, specialization_ability_id TEXT NOT NULL REFERENCES specialization_abilities(id), @@ -2122,35 +3017,51 @@ if (skillChipAbilityColumn && Number(skillChipAbilityColumn.notnull) !== 1) { created_at TEXT NOT NULL, updated_at TEXT NOT NULL )`); - await database.exec(`INSERT INTO skill_chip_sets_current(id,name,specialization_ability_id,active,created_at,updated_at) + await database.exec(`INSERT INTO skill_chip_sets_current(id,name,specialization_ability_id,active,created_at,updated_at) SELECT id,name,specialization_ability_id,active,created_at,updated_at FROM skill_chip_sets`); - await database.exec('DROP TABLE skill_chip_sets'); - await database.exec('ALTER TABLE skill_chip_sets_current RENAME TO skill_chip_sets'); - await database.exec('COMMIT'); - } catch (error) { - await database.exec('ROLLBACK'); - throw error; - } finally { - await database.exec('PRAGMA foreign_keys=ON'); - } + await database.exec('DROP TABLE skill_chip_sets'); + await database.exec( + 'ALTER TABLE skill_chip_sets_current RENAME TO skill_chip_sets', + ); + await database.exec('COMMIT'); + } catch (error) { + await database.exec('ROLLBACK'); + throw error; + } finally { + await database.exec('PRAGMA foreign_keys=ON'); + } } -await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS skill_chip_sets_name_nocase_idx ON skill_chip_sets(name COLLATE NOCASE)'); -await database.exec('CREATE INDEX IF NOT EXISTS skill_chip_sets_ability_idx ON skill_chip_sets(specialization_ability_id,active)'); +await database.exec( + 'CREATE UNIQUE INDEX IF NOT EXISTS skill_chip_sets_name_nocase_idx ON skill_chip_sets(name COLLATE NOCASE)', +); +await database.exec( + 'CREATE INDEX IF NOT EXISTS skill_chip_sets_ability_idx ON skill_chip_sets(specialization_ability_id,active)', +); -const conditionalOutcomeSchema = await (await database.prepare( - "SELECT sql FROM sqlite_master WHERE type='table' AND name='conditional_effect_outcomes'" -)).get(); +const conditionalOutcomeSchema = await ( + await database.prepare( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='conditional_effect_outcomes'", + ) +).get(); if ( - conditionalOutcomeSchema - && !String(conditionalOutcomeSchema.sql ?? '').includes('signature_ability_charge') + conditionalOutcomeSchema && + !String(conditionalOutcomeSchema.sql ?? '').includes( + 'signature_ability_charge', + ) ) { - console.log('Adding Signature Ability Charge as a conditional-mechanic resource.'); - await database.exec('DROP INDEX IF EXISTS conditional_effect_outcomes_rule_idx'); - await database.exec('PRAGMA foreign_keys=OFF'); - await database.exec('BEGIN'); - try { - await database.exec('DROP TABLE IF EXISTS conditional_effect_outcomes_signature_charge'); - await database.exec(`CREATE TABLE conditional_effect_outcomes_signature_charge ( + console.log( + 'Adding Signature Ability Charge as a conditional-mechanic resource.', + ); + await database.exec( + 'DROP INDEX IF EXISTS conditional_effect_outcomes_rule_idx', + ); + await database.exec('PRAGMA foreign_keys=OFF'); + await database.exec('BEGIN'); + try { + await database.exec( + 'DROP TABLE IF EXISTS conditional_effect_outcomes_signature_charge', + ); + await database.exec(`CREATE TABLE conditional_effect_outcomes_signature_charge ( id TEXT PRIMARY KEY, rule_id TEXT NOT NULL REFERENCES conditional_effect_rules(id) ON DELETE CASCADE, clause_id TEXT REFERENCES conditional_effect_clauses(id) ON DELETE CASCADE, @@ -2209,7 +3120,7 @@ if ( ) ) )`); - await database.exec(`INSERT INTO conditional_effect_outcomes_signature_charge ( + await database.exec(`INSERT INTO conditional_effect_outcomes_signature_charge ( id,rule_id,clause_id,state_pool_id,effect_key,variant_key,outcome_key, application_kind,effect_scope,stat_attribute_id,value,value_expression_json, value_basis,value_scaling,unit,damage_basis,application_phase,combat_timing, @@ -2223,35 +3134,43 @@ if ( resource_kind,delivery_mode,delivery_duration_seconds,min_state_value,max_state_value, description_slot,sort_order,created_at,updated_at FROM conditional_effect_outcomes`); - await database.exec('DROP TABLE conditional_effect_outcomes'); - await database.exec( - 'ALTER TABLE conditional_effect_outcomes_signature_charge RENAME TO conditional_effect_outcomes' - ); - await database.exec('COMMIT'); - } catch (error) { - await database.exec('ROLLBACK'); - throw error; - } finally { - await database.exec('PRAGMA foreign_keys=ON'); - } - await database.exec(`CREATE INDEX IF NOT EXISTS conditional_effect_outcomes_rule_idx + await database.exec('DROP TABLE conditional_effect_outcomes'); + await database.exec( + 'ALTER TABLE conditional_effect_outcomes_signature_charge RENAME TO conditional_effect_outcomes', + ); + await database.exec('COMMIT'); + } catch (error) { + await database.exec('ROLLBACK'); + throw error; + } finally { + await database.exec('PRAGMA foreign_keys=ON'); + } + await database.exec(`CREATE INDEX IF NOT EXISTS conditional_effect_outcomes_rule_idx ON conditional_effect_outcomes(rule_id,variant_key,outcome_key,sort_order)`); } -const reloadOutcomeSchema = await (await database.prepare( - "SELECT sql FROM sqlite_master WHERE type='table' AND name='conditional_effect_outcomes'" -)).get(); +const reloadOutcomeSchema = await ( + await database.prepare( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='conditional_effect_outcomes'", + ) +).get(); if ( - reloadOutcomeSchema - && !String(reloadOutcomeSchema.sql ?? '').includes("'weapon_reload'") + reloadOutcomeSchema && + !String(reloadOutcomeSchema.sql ?? '').includes("'weapon_reload'") ) { - console.log('Adding inactive-weapon reload actions to conditional mechanics.'); - await database.exec('DROP INDEX IF EXISTS conditional_effect_outcomes_rule_idx'); - await database.exec('PRAGMA foreign_keys=OFF'); - await database.exec('BEGIN'); - try { - await database.exec('DROP TABLE IF EXISTS conditional_effect_outcomes_weapon_reload'); - await database.exec(`CREATE TABLE conditional_effect_outcomes_weapon_reload ( + console.log( + 'Adding inactive-weapon reload actions to conditional mechanics.', + ); + await database.exec( + 'DROP INDEX IF EXISTS conditional_effect_outcomes_rule_idx', + ); + await database.exec('PRAGMA foreign_keys=OFF'); + await database.exec('BEGIN'); + try { + await database.exec( + 'DROP TABLE IF EXISTS conditional_effect_outcomes_weapon_reload', + ); + await database.exec(`CREATE TABLE conditional_effect_outcomes_weapon_reload ( id TEXT PRIMARY KEY, rule_id TEXT NOT NULL REFERENCES conditional_effect_rules(id) ON DELETE CASCADE, clause_id TEXT REFERENCES conditional_effect_clauses(id) ON DELETE CASCADE, @@ -2311,7 +3230,7 @@ if ( ) ) )`); - await database.exec(`INSERT INTO conditional_effect_outcomes_weapon_reload ( + await database.exec(`INSERT INTO conditional_effect_outcomes_weapon_reload ( id,rule_id,clause_id,state_pool_id,effect_key,variant_key,outcome_key, application_kind,effect_scope,stat_attribute_id,value,value_expression_json, value_basis,value_scaling,unit,damage_basis,application_phase,combat_timing, @@ -2325,43 +3244,62 @@ if ( resource_kind,delivery_mode,delivery_duration_seconds,min_state_value,max_state_value, description_slot,sort_order,created_at,updated_at FROM conditional_effect_outcomes`); - await database.exec('DROP TABLE conditional_effect_outcomes'); - await database.exec( - 'ALTER TABLE conditional_effect_outcomes_weapon_reload RENAME TO conditional_effect_outcomes' - ); - await database.exec('COMMIT'); - } catch (error) { - await database.exec('ROLLBACK'); - throw error; - } finally { - await database.exec('PRAGMA foreign_keys=ON'); - } - await database.exec(`CREATE INDEX IF NOT EXISTS conditional_effect_outcomes_rule_idx + await database.exec('DROP TABLE conditional_effect_outcomes'); + await database.exec( + 'ALTER TABLE conditional_effect_outcomes_weapon_reload RENAME TO conditional_effect_outcomes', + ); + await database.exec('COMMIT'); + } catch (error) { + await database.exec('ROLLBACK'); + throw error; + } finally { + await database.exec('PRAGMA foreign_keys=ON'); + } + await database.exec(`CREATE INDEX IF NOT EXISTS conditional_effect_outcomes_rule_idx ON conditional_effect_outcomes(rule_id,variant_key,outcome_key,sort_order)`); } const outcomeTargetingColumns = await ( - await database.prepare("PRAGMA table_info('conditional_effect_outcomes')") + await database.prepare("PRAGMA table_info('conditional_effect_outcomes')") ).all(); -if (!outcomeTargetingColumns.some((column) => column.name === 'targeting_json')) { - await database.exec('ALTER TABLE conditional_effect_outcomes ADD COLUMN targeting_json TEXT'); +if ( + !outcomeTargetingColumns.some((column) => column.name === 'targeting_json') +) { + await database.exec( + 'ALTER TABLE conditional_effect_outcomes ADD COLUMN targeting_json TEXT', + ); } -const calibrationCases = await (await database.prepare( - 'SELECT id,loadout_snapshot_json FROM calculation_calibration_cases' -)).all(); +const calibrationCases = await ( + await database.prepare( + 'SELECT id,loadout_snapshot_json FROM calculation_calibration_cases', + ) +).all(); const obsoleteCalibrationIds = calibrationCases - .filter((calibrationCase) => !isCurrentLoadoutSnapshot(calibrationCase.loadout_snapshot_json)) - .map((calibrationCase) => String(calibrationCase.id)); + .filter( + (calibrationCase) => + !isCurrentLoadoutSnapshot(calibrationCase.loadout_snapshot_json), + ) + .map((calibrationCase) => String(calibrationCase.id)); if (obsoleteCalibrationIds.length) { - console.log(`Removing ${obsoleteCalibrationIds.length} calculation calibration case(s) with obsolete loadout snapshots.`); - for (const id of obsoleteCalibrationIds) { - await (await database.prepare('DELETE FROM calculation_calibration_cases WHERE id=?')).run(id); - } + console.log( + `Removing ${obsoleteCalibrationIds.length} calculation calibration case(s) with obsolete loadout snapshots.`, + ); + for (const id of obsoleteCalibrationIds) { + await ( + await database.prepare( + 'DELETE FROM calculation_calibration_cases WHERE id=?', + ) + ).run(id); + } } -const foreignKeyFailures = await (await database.prepare('PRAGMA foreign_key_check')).all(); +const foreignKeyFailures = await ( + await database.prepare('PRAGMA foreign_key_check') +).all(); if (foreignKeyFailures.length) { - throw new Error(`Schema migration left ${foreignKeyFailures.length} foreign-key violation(s).`); + throw new Error( + `Schema migration left ${foreignKeyFailures.length} foreign-key violation(s).`, + ); } await database.exec(`UPDATE specialization_focuses SET builder_ready=0,updated_at='${new Date().toISOString()}' WHERE builder_ready=1 AND ( @@ -2373,11 +3311,15 @@ await database.exec(`UPDATE specialization_focuses SET builder_ready=0,updated_a )=3 )<>3`); -console.log(`Applied ${statements.length} schema statements to ${databasePath}`); +console.log( + `Applied ${statements.length} schema statements to ${databasePath}`, +); if (!databaseAlreadyExisted && process.env.SKIP_DEFAULT_USERS !== 'true') { - console.log('New database detected; creating default users.'); - await initializeDefaultUsers(database); + console.log('New database detected; creating default users.'); + await initializeDefaultUsers(database); } else if (!databaseAlreadyExisted) { - console.log('New database detected; default-user seeding is disabled for this environment.'); + console.log( + 'New database detected; default-user seeding is disabled for this environment.', + ); } diff --git a/scripts/db-restore.mjs b/scripts/db-restore.mjs index c29f3b9..0b1481a 100644 --- a/scripts/db-restore.mjs +++ b/scripts/db-restore.mjs @@ -5,53 +5,64 @@ import process from 'node:process'; const backupArgument = process.argv[2]; if (!backupArgument) { - console.error('Usage: npm run db:restore -- '); - process.exit(2); + console.error('Usage: npm run db:restore -- '); + process.exit(2); } const backupPath = resolve(backupArgument); -const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build-development.db'); +const databasePath = resolve( + process.env.TDR_DATABASE_PATH || './data/shd-build-development.db', +); const backup = JSON.parse(await readFile(backupPath, 'utf8')); -if (backup?.format !== 'shd-build-database-backup' || backup?.version !== 1 || !Array.isArray(backup.tables)) { - throw new Error('The selected file is not a supported SHD.build database backup.'); +if ( + backup?.format !== 'shd-build-database-backup' || + backup?.version !== 1 || + !Array.isArray(backup.tables) +) { + throw new Error( + 'The selected file is not a supported SHD.build database backup.', + ); } -const quoteIdentifier = (identifier) => `"${String(identifier).replaceAll('"', '""')}"`; +const quoteIdentifier = (identifier) => + `"${String(identifier).replaceAll('"', '""')}"`; const database = await connect(databasePath); // Restores are deliberately limited to empty databases. This prevents an // accidental merge from overwriting contributor-entered records. for (const table of backup.tables) { - const existing = await ( - await database.prepare(`SELECT COUNT(*) AS count FROM ${quoteIdentifier(table.name)}`) - ).get(); - if (Number(existing?.count) > 0) { - throw new Error( - `Restore refused: table "${table.name}" is not empty. Use a new migrated database.` - ); - } + const existing = await ( + await database.prepare( + `SELECT COUNT(*) AS count FROM ${quoteIdentifier(table.name)}`, + ) + ).get(); + if (Number(existing?.count) > 0) { + throw new Error( + `Restore refused: table "${table.name}" is not empty. Use a new migrated database.`, + ); + } } let restoredRows = 0; await database.exec('BEGIN'); try { - for (const table of backup.tables) { - for (const row of table.rows) { - const columns = Object.keys(row); - if (!columns.length) continue; - const columnSql = columns.map(quoteIdentifier).join(', '); - const placeholders = columns.map(() => '?').join(', '); - const insert = await database.prepare( - `INSERT INTO ${quoteIdentifier(table.name)} (${columnSql}) VALUES (${placeholders})` - ); - await insert.run(...columns.map((column) => row[column])); - restoredRows++; - } - } - await database.exec('COMMIT'); + for (const table of backup.tables) { + for (const row of table.rows) { + const columns = Object.keys(row); + if (!columns.length) continue; + const columnSql = columns.map(quoteIdentifier).join(', '); + const placeholders = columns.map(() => '?').join(', '); + const insert = await database.prepare( + `INSERT INTO ${quoteIdentifier(table.name)} (${columnSql}) VALUES (${placeholders})`, + ); + await insert.run(...columns.map((column) => row[column])); + restoredRows++; + } + } + await database.exec('COMMIT'); } catch (error) { - await database.exec('ROLLBACK'); - throw error; + await database.exec('ROLLBACK'); + throw error; } console.log(`Restored ${restoredRows} rows into ${databasePath}.`); diff --git a/scripts/default-users.mjs b/scripts/default-users.mjs index a4afb5d..381fe1d 100644 --- a/scripts/default-users.mjs +++ b/scripts/default-users.mjs @@ -2,30 +2,30 @@ import { argon2id, hash } from 'argon2'; import { randomBytes, randomUUID } from 'node:crypto'; export const defaultUsers = [ - { - username: 'Aewyn', - role: 'user', - passwordEnvironmentKey: 'DEFAULT_ADMIN_PASSWORD' - }, - { - username: 'Quartermaster', - role: 'user', - passwordEnvironmentKey: 'DEFAULT_USER_PASSWORD' - } + { + username: 'Aewyn', + role: 'user', + passwordEnvironmentKey: 'DEFAULT_ADMIN_PASSWORD', + }, + { + username: 'Quartermaster', + role: 'user', + passwordEnvironmentKey: 'DEFAULT_USER_PASSWORD', + }, ]; function temporaryPassword() { - // 120 bits of entropy encoded without punctuation that is awkward to paste. - return randomBytes(15).toString('base64url'); + // 120 bits of entropy encoded without punctuation that is awkward to paste. + return randomBytes(15).toString('base64url'); } async function secureHash(password) { - return hash(password, { - type: argon2id, - memoryCost: 19_456, - timeCost: 2, - parallelism: 1 - }); + return hash(password, { + type: argon2id, + memoryCost: 19_456, + timeCost: 2, + parallelism: 1, + }); } /** @@ -33,61 +33,78 @@ async function secureHash(password) { * users keep their IDs and related records but receive new password hashes. */ export async function initializeDefaultUsers(database, options = {}) { - const resetPasswords = options.resetPasswords === true; - const findUser = await database.prepare( - 'SELECT id, username, role FROM users WHERE login_username = ? COLLATE NOCASE OR username = ? COLLATE NOCASE' - ); - const insertUser = await database.prepare( - 'INSERT INTO users (id, username, login_username, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?, ?)' - ); - const updateCredential = await database.prepare( - 'UPDATE users SET login_username = ?, password_hash = ?, role = ? WHERE id = ?' - ); - const credentials = []; + const resetPasswords = options.resetPasswords === true; + const findUser = await database.prepare( + 'SELECT id, username, role FROM users WHERE login_username = ? COLLATE NOCASE OR username = ? COLLATE NOCASE', + ); + const insertUser = await database.prepare( + 'INSERT INTO users (id, username, login_username, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?, ?)', + ); + const updateCredential = await database.prepare( + 'UPDATE users SET login_username = ?, password_hash = ?, role = ? WHERE id = ?', + ); + const credentials = []; - for (const definition of defaultUsers) { - const existing = await findUser.get(definition.username, definition.username); - if (existing && !resetPasswords) { - console.log(`${definition.username}: already exists (${existing.role}); left unchanged`); - continue; - } + for (const definition of defaultUsers) { + const existing = await findUser.get( + definition.username, + definition.username, + ); + if (existing && !resetPasswords) { + console.log( + `${definition.username}: already exists (${existing.role}); left unchanged`, + ); + continue; + } - const configuredPassword = process.env[definition.passwordEnvironmentKey]?.trim(); - const password = configuredPassword || temporaryPassword(); - if (password.length < 12 || password.length > 128) { - throw new Error(`${definition.passwordEnvironmentKey} must be between 12 and 128 characters.`); - } - const passwordHash = await secureHash(password); + const configuredPassword = + process.env[definition.passwordEnvironmentKey]?.trim(); + const password = configuredPassword || temporaryPassword(); + if (password.length < 12 || password.length > 128) { + throw new Error( + `${definition.passwordEnvironmentKey} must be between 12 and 128 characters.`, + ); + } + const passwordHash = await secureHash(password); - if (existing) { - await updateCredential.run(definition.username, passwordHash, String(existing.role), existing.id); - console.log(`${definition.username}: credentials reset; role and related data preserved`); - } else { - await insertUser.run( - randomUUID(), - definition.username, - definition.username, - passwordHash, - definition.role, - new Date().toISOString() - ); - console.log(`${definition.username}: default account created`); - } - credentials.push({ - username: definition.username, - role: existing ? String(existing.role) : definition.role, - password, - generated: !configuredPassword - }); - } + if (existing) { + await updateCredential.run( + definition.username, + passwordHash, + String(existing.role), + existing.id, + ); + console.log( + `${definition.username}: credentials reset; role and related data preserved`, + ); + } else { + await insertUser.run( + randomUUID(), + definition.username, + definition.username, + passwordHash, + definition.role, + new Date().toISOString(), + ); + console.log(`${definition.username}: default account created`); + } + credentials.push({ + username: definition.username, + role: existing ? String(existing.role) : definition.role, + password, + generated: !configuredPassword, + }); + } - if (credentials.length) { - console.log('\nDefault user credentials (shown once):'); - for (const credential of credentials) { - console.log(`${credential.username} (${credential.role}): ${credential.password}${credential.generated ? ' [generated]' : ' [from environment]'}`); - } - console.log('Change generated passwords after the first sign-in.\n'); - } + if (credentials.length) { + console.log('\nDefault user credentials (shown once):'); + for (const credential of credentials) { + console.log( + `${credential.username} (${credential.role}): ${credential.password}${credential.generated ? ' [generated]' : ' [from environment]'}`, + ); + } + console.log('Change generated passwords after the first sign-in.\n'); + } - return credentials; + return credentials; } diff --git a/scripts/env-files.mjs b/scripts/env-files.mjs index cc96440..b506ef6 100644 --- a/scripts/env-files.mjs +++ b/scripts/env-files.mjs @@ -8,25 +8,28 @@ const gitCommand = process.platform === 'win32' ? 'git.exe' : 'git'; const dotenvxCli = resolve('node_modules/@dotenvx/dotenvx/src/cli/dotenvx.js'); function run(command, args) { - const result = spawnSync(command, args, { cwd: process.cwd(), stdio: 'inherit' }); - if (result.error) throw result.error; - if (result.status !== 0) process.exit(result.status ?? 1); + const result = spawnSync(command, args, { + cwd: process.cwd(), + stdio: 'inherit', + }); + if (result.error) throw result.error; + if (result.status !== 0) process.exit(result.status ?? 1); } function dotenvx(command, file) { - run(process.execPath, [dotenvxCli, command, '-f', file]); + run(process.execPath, [dotenvxCli, command, '-f', file]); } if (!['encrypt', 'decrypt', 'stage'].includes(action)) { - console.error('Usage: node scripts/env-files.mjs '); - process.exit(2); + console.error('Usage: node scripts/env-files.mjs '); + process.exit(2); } for (const file of files) { - dotenvx(action === 'decrypt' ? 'decrypt' : 'encrypt', file); + dotenvx(action === 'decrypt' ? 'decrypt' : 'encrypt', file); } if (action === 'stage') { - run(gitCommand, ['add', '--', ...files]); - console.log('Encrypted and staged environment files.'); + run(gitCommand, ['add', '--', ...files]); + console.log('Encrypted and staged environment files.'); } diff --git a/scripts/environment-bootstrap-export.mjs b/scripts/environment-bootstrap-export.mjs index 773d053..793cdb4 100644 --- a/scripts/environment-bootstrap-export.mjs +++ b/scripts/environment-bootstrap-export.mjs @@ -5,50 +5,81 @@ import process from 'node:process'; const target = process.argv[2]; if (!['staging', 'production'].includes(target)) { - console.error('Usage: node scripts/environment-bootstrap-export.mjs [output.json]'); - process.exit(2); + console.error( + 'Usage: node scripts/environment-bootstrap-export.mjs [output.json]', + ); + process.exit(2); } const outputArgument = process.argv[3]; -const timestamp = new Date().toISOString().replaceAll(':', '-').replace(/\.\d{3}Z$/, 'Z'); -const outputPath = resolve(outputArgument || `./data/backups/shd-${target}-bootstrap-${timestamp}.json`); +const timestamp = new Date() + .toISOString() + .replaceAll(':', '-') + .replace(/\.\d{3}Z$/, 'Z'); +const outputPath = resolve( + outputArgument || `./data/backups/shd-${target}-bootstrap-${timestamp}.json`, +); const temporaryBackupPath = `${outputPath}.database.json`; -const databaseBackup = spawnSync(process.execPath, ['scripts/db-backup.mjs', temporaryBackupPath], { - cwd: process.cwd(), - stdio: 'inherit', - env: process.env -}); +const databaseBackup = spawnSync( + process.execPath, + ['scripts/db-backup.mjs', temporaryBackupPath], + { + cwd: process.cwd(), + stdio: 'inherit', + env: process.env, + }, +); if (databaseBackup.error) throw databaseBackup.error; -if (databaseBackup.status !== 0) throw new Error(`Database backup failed; no ${target} bootstrap bundle was created.`); +if (databaseBackup.status !== 0) + throw new Error( + `Database backup failed; no ${target} bootstrap bundle was created.`, + ); async function collectFiles(directory, pattern) { - try { - const names = (await readdir(directory)).filter((name) => pattern.test(name)).sort(); - return await Promise.all(names.map(async (name) => ({ - name, - contentBase64: (await readFile(join(directory, name))).toString('base64') - }))); - } catch (error) { - if (error?.code === 'ENOENT') return []; - throw error; - } + try { + const names = (await readdir(directory)) + .filter((name) => pattern.test(name)) + .sort(); + return await Promise.all( + names.map(async (name) => ({ + name, + contentBase64: (await readFile(join(directory, name))).toString( + 'base64', + ), + })), + ); + } catch (error) { + if (error?.code === 'ENOENT') return []; + throw error; + } } try { - const database = JSON.parse(await readFile(temporaryBackupPath, 'utf8')); - const avatars = await collectFiles(resolve(process.env.TDR_AVATAR_STORAGE_PATH || './data/avatars'), /^[0-9a-f-]{36}\.webp$/i); - const gameIcons = await collectFiles(resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons'), /^[a-z_]+-[0-9a-f-]{36}\.webp$/i); - const bundle = { - format: 'shd-build-environment-bootstrap', - version: 1, - target, - createdAt: new Date().toISOString(), - database, - files: { avatars, gameIcons } - }; - await mkdir(dirname(outputPath), { recursive: true }); - await writeFile(outputPath, `${JSON.stringify(bundle, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' }); - console.log(`${target} bootstrap bundle includes ${avatars.length} avatars and ${gameIcons.length} managed image assets.`); - console.log(`Bootstrap bundle: ${outputPath}`); + const database = JSON.parse(await readFile(temporaryBackupPath, 'utf8')); + const avatars = await collectFiles( + resolve(process.env.TDR_AVATAR_STORAGE_PATH || './data/avatars'), + /^[0-9a-f-]{36}\.webp$/i, + ); + const gameIcons = await collectFiles( + resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons'), + /^[a-z_]+-[0-9a-f-]{36}\.webp$/i, + ); + const bundle = { + format: 'shd-build-environment-bootstrap', + version: 1, + target, + createdAt: new Date().toISOString(), + database, + files: { avatars, gameIcons }, + }; + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, `${JSON.stringify(bundle, null, 2)}\n`, { + encoding: 'utf8', + flag: 'wx', + }); + console.log( + `${target} bootstrap bundle includes ${avatars.length} avatars and ${gameIcons.length} managed image assets.`, + ); + console.log(`Bootstrap bundle: ${outputPath}`); } finally { - await rm(temporaryBackupPath, { force: true }); + await rm(temporaryBackupPath, { force: true }); } diff --git a/scripts/environment-bootstrap-import.mjs b/scripts/environment-bootstrap-import.mjs index ceabafe..2feb716 100644 --- a/scripts/environment-bootstrap-import.mjs +++ b/scripts/environment-bootstrap-import.mjs @@ -4,112 +4,178 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { join, resolve } from 'node:path'; import process from 'node:process'; -const bundleArgument = process.argv.slice(2).find((argument) => !argument.startsWith('--')); +const bundleArgument = process.argv + .slice(2) + .find((argument) => !argument.startsWith('--')); const targetIndex = process.argv.indexOf('--target'); const target = targetIndex >= 0 ? process.argv[targetIndex + 1] : ''; -const allowed = target === 'staging' - ? process.env.ALLOW_STAGING_BOOTSTRAP === 'true' - : target === 'production' - ? process.env.ALLOW_PRODUCTION_BOOTSTRAP === 'true' - : false; -if (!bundleArgument || !process.argv.includes('--confirm-empty-target-replace') || !allowed) { - console.error('Environment bootstrap requires the matching ALLOW_*_BOOTSTRAP=true guard and:'); - console.error('node scripts/environment-bootstrap-import.mjs --target --confirm-empty-target-replace'); - process.exit(2); +const allowed = + target === 'staging' + ? process.env.ALLOW_STAGING_BOOTSTRAP === 'true' + : target === 'production' + ? process.env.ALLOW_PRODUCTION_BOOTSTRAP === 'true' + : false; +if ( + !bundleArgument || + !process.argv.includes('--confirm-empty-target-replace') || + !allowed +) { + console.error( + 'Environment bootstrap requires the matching ALLOW_*_BOOTSTRAP=true guard and:', + ); + console.error( + 'node scripts/environment-bootstrap-import.mjs --target --confirm-empty-target-replace', + ); + process.exit(2); } const bundlePath = resolve(bundleArgument); const bundle = JSON.parse(await readFile(bundlePath, 'utf8')); -if (bundle?.format !== 'shd-build-environment-bootstrap' || bundle?.version !== 1 || bundle?.target !== target - || bundle.database?.format !== 'shd-build-database-backup' || bundle.database?.version !== 1 - || !Array.isArray(bundle.database.tables) || !Array.isArray(bundle.files?.avatars) - || !Array.isArray(bundle.files?.gameIcons)) { - throw new Error('The selected file is not a supported SHD.build bootstrap bundle for the selected target.'); +if ( + bundle?.format !== 'shd-build-environment-bootstrap' || + bundle?.version !== 1 || + bundle?.target !== target || + bundle.database?.format !== 'shd-build-database-backup' || + bundle.database?.version !== 1 || + !Array.isArray(bundle.database.tables) || + !Array.isArray(bundle.files?.avatars) || + !Array.isArray(bundle.files?.gameIcons) +) { + throw new Error( + 'The selected file is not a supported SHD.build bootstrap bundle for the selected target.', + ); } for (const file of bundle.files.avatars) { - if (typeof file.name !== 'string' || !/^[0-9a-f-]{36}\.webp$/i.test(file.name) || typeof file.contentBase64 !== 'string') { - throw new Error('The bootstrap bundle contains an invalid avatar file.'); - } + if ( + typeof file.name !== 'string' || + !/^[0-9a-f-]{36}\.webp$/i.test(file.name) || + typeof file.contentBase64 !== 'string' + ) { + throw new Error('The bootstrap bundle contains an invalid avatar file.'); + } } for (const file of bundle.files.gameIcons) { - if (typeof file.name !== 'string' || !/^[a-z_]+-[0-9a-f-]{36}\.webp$/i.test(file.name) || typeof file.contentBase64 !== 'string') { - throw new Error('The bootstrap bundle contains an invalid game-data icon file.'); - } + if ( + typeof file.name !== 'string' || + !/^[a-z_]+-[0-9a-f-]{36}\.webp$/i.test(file.name) || + typeof file.contentBase64 !== 'string' + ) { + throw new Error( + 'The bootstrap bundle contains an invalid game-data icon file.', + ); + } } -const databasePath = resolve(process.env.TDR_DATABASE_PATH || `./data/shd-build-${target}.db`); +const databasePath = resolve( + process.env.TDR_DATABASE_PATH || `./data/shd-build-${target}.db`, +); const safetyBackup = spawnSync(process.execPath, ['scripts/db-backup.mjs'], { - cwd: process.cwd(), - stdio: 'inherit', - env: process.env + cwd: process.cwd(), + stdio: 'inherit', + env: process.env, }); if (safetyBackup.error) throw safetyBackup.error; -if (safetyBackup.status !== 0) throw new Error(`${target} safety backup failed; the target was not changed.`); +if (safetyBackup.status !== 0) + throw new Error( + `${target} safety backup failed; the target was not changed.`, + ); const database = await connect(databasePath); -for (const tableName of ['users', 'builds', 'audit_log', 'data_change_proposals']) { - const row = await (await database.prepare(`SELECT COUNT(*) AS count FROM "${tableName}"`)).get(); - if (Number(row?.count ?? 0) > 0) { - throw new Error(`${target} bootstrap refused: target table "${tableName}" is not empty.`); - } +for (const tableName of [ + 'users', + 'builds', + 'audit_log', + 'data_change_proposals', +]) { + const row = await ( + await database.prepare(`SELECT COUNT(*) AS count FROM "${tableName}"`) + ).get(); + if (Number(row?.count ?? 0) > 0) { + throw new Error( + `${target} bootstrap refused: target table "${tableName}" is not empty.`, + ); + } } -const targetTables = await (await database.prepare(` +const targetTables = await ( + await database.prepare(` SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY rowid -`)).all(); +`) +).all(); const targetNames = new Set(targetTables.map((row) => String(row.name))); -const sourceNames = new Set(bundle.database.tables.map((table) => String(table.name))); +const sourceNames = new Set( + bundle.database.tables.map((table) => String(table.name)), +); for (const row of targetTables) { - if (!sourceNames.has(String(row.name))) { - throw new Error(`Bootstrap snapshot is missing target-schema table "${String(row.name)}".`); - } + if (!sourceNames.has(String(row.name))) { + throw new Error( + `Bootstrap snapshot is missing target-schema table "${String(row.name)}".`, + ); + } } const sourceOnlyTables = bundle.database.tables - .map((table) => String(table.name)) - .filter((name) => !targetNames.has(name)); + .map((table) => String(table.name)) + .filter((name) => !targetNames.has(name)); if (sourceOnlyTables.length) { - throw new Error(`Bootstrap snapshot contains tables outside the current target schema: ${sourceOnlyTables.join(', ')}`); + throw new Error( + `Bootstrap snapshot contains tables outside the current target schema: ${sourceOnlyTables.join(', ')}`, + ); } -const quoteIdentifier = (identifier) => `"${String(identifier).replaceAll('"', '""')}"`; +const quoteIdentifier = (identifier) => + `"${String(identifier).replaceAll('"', '""')}"`; let restoredRows = 0; await database.exec('PRAGMA foreign_keys=OFF'); await database.exec('BEGIN'); try { - for (const row of [...targetTables].reverse()) { - await database.exec(`DELETE FROM ${quoteIdentifier(row.name)}`); - } - for (const table of bundle.database.tables) { - for (const row of table.rows) { - const columns = Object.keys(row); - if (!columns.length) continue; - const insert = await database.prepare( - `INSERT INTO ${quoteIdentifier(table.name)} (${columns.map(quoteIdentifier).join(', ')}) - VALUES (${columns.map(() => '?').join(', ')})` - ); - await insert.run(...columns.map((column) => row[column])); - restoredRows++; - } - } - await database.exec('COMMIT'); + for (const row of [...targetTables].reverse()) { + await database.exec(`DELETE FROM ${quoteIdentifier(row.name)}`); + } + for (const table of bundle.database.tables) { + for (const row of table.rows) { + const columns = Object.keys(row); + if (!columns.length) continue; + const insert = await database.prepare( + `INSERT INTO ${quoteIdentifier(table.name)} (${columns.map(quoteIdentifier).join(', ')}) + VALUES (${columns.map(() => '?').join(', ')})`, + ); + await insert.run(...columns.map((column) => row[column])); + restoredRows++; + } + } + await database.exec('COMMIT'); } catch (error) { - await database.exec('ROLLBACK'); - throw error; + await database.exec('ROLLBACK'); + throw error; } finally { - await database.exec('PRAGMA foreign_keys=ON'); + await database.exec('PRAGMA foreign_keys=ON'); } -const avatarDirectory = resolve(process.env.TDR_AVATAR_STORAGE_PATH || './data/avatars'); -const gameIconDirectory = resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons'); +const avatarDirectory = resolve( + process.env.TDR_AVATAR_STORAGE_PATH || './data/avatars', +); +const gameIconDirectory = resolve( + process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons', +); await mkdir(avatarDirectory, { recursive: true }); await mkdir(gameIconDirectory, { recursive: true }); for (const file of bundle.files.avatars) { - await writeFile(join(avatarDirectory, file.name), Buffer.from(file.contentBase64, 'base64'), { mode: 0o600 }); + await writeFile( + join(avatarDirectory, file.name), + Buffer.from(file.contentBase64, 'base64'), + { mode: 0o600 }, + ); } for (const file of bundle.files.gameIcons) { - await writeFile(join(gameIconDirectory, file.name), Buffer.from(file.contentBase64, 'base64'), { mode: 0o600 }); + await writeFile( + join(gameIconDirectory, file.name), + Buffer.from(file.contentBase64, 'base64'), + { mode: 0o600 }, + ); } -console.log(`Bootstrapped ${target} with ${restoredRows} database rows, ${bundle.files.avatars.length} avatars, and ${bundle.files.gameIcons.length} managed image assets.`); +console.log( + `Bootstrapped ${target} with ${restoredRows} database rows, ${bundle.files.avatars.length} avatars, and ${bundle.files.gameIcons.length} managed image assets.`, +); console.log(`Source bundle: ${bundlePath}`); diff --git a/scripts/import-resurgence-attributes.mjs b/scripts/import-resurgence-attributes.mjs index 9f2ca25..beaed7f 100644 --- a/scripts/import-resurgence-attributes.mjs +++ b/scripts/import-resurgence-attributes.mjs @@ -4,121 +4,186 @@ import { randomUUID } from 'node:crypto'; import { isDeepStrictEqual } from 'node:util'; import { resolve } from 'node:path'; -const sourcePath = resolve(process.argv[2] || './scripts/data/resurgence-attributes.json'); +const sourcePath = resolve( + process.argv[2] || './scripts/data/resurgence-attributes.json', +); const records = JSON.parse(await readFile(sourcePath, 'utf8')); -if (!Array.isArray(records) || !records.length) throw new Error('The Attribute import source is empty or invalid.'); +if (!Array.isArray(records) || !records.length) + throw new Error('The Attribute import source is empty or invalid.'); const identities = new Set(); for (const record of records) { - const identity = `${String(record.name).normalize('NFKC').trim().toLocaleLowerCase()}\u0000${String(record.unit ?? '')}`; - if (identities.has(identity)) throw new Error(`Duplicate Attribute identity in import source: ${record.name} (${record.unit || 'flat'})`); - identities.add(identity); - if (!['Offensive', 'Defensive'].includes(record.type) - || !['Weapon', 'Skill', 'Character'].includes(record.category) - || !['Additive', 'Multiplicative'].includes(record.calculationType) - || !['', '%', 's', 'Shots', 'm'].includes(record.unit ?? '')) { - throw new Error(`Invalid Attribute classification in import source: ${record.name}`); - } - for (const range of [record.gearRange, record.weaponRange]) { - if (range === undefined) continue; - if (!Array.isArray(range) || range.length !== 2 || !range.every(Number.isFinite) || range[0] > range[1]) { - throw new Error(`Invalid source range in import source: ${record.name}`); - } - } + const identity = `${String(record.name).normalize('NFKC').trim().toLocaleLowerCase()}\u0000${String(record.unit ?? '')}`; + if (identities.has(identity)) + throw new Error( + `Duplicate Attribute identity in import source: ${record.name} (${record.unit || 'flat'})`, + ); + identities.add(identity); + if ( + !['Offensive', 'Defensive'].includes(record.type) || + !['Weapon', 'Skill', 'Character'].includes(record.category) || + !['Additive', 'Multiplicative'].includes(record.calculationType) || + !['', '%', 's', 'Shots', 'm'].includes(record.unit ?? '') + ) { + throw new Error( + `Invalid Attribute classification in import source: ${record.name}`, + ); + } + for (const range of [record.gearRange, record.weaponRange]) { + if (range === undefined) continue; + if ( + !Array.isArray(range) || + range.length !== 2 || + !range.every(Number.isFinite) || + range[0] > range[1] + ) { + throw new Error(`Invalid source range in import source: ${record.name}`); + } + } } -const vite = await createServer({ server: { middlewareMode: true }, appType: 'custom', logLevel: 'error' }); +const vite = await createServer({ + server: { middlewareMode: true }, + appType: 'custom', + logLevel: 'error', +}); try { - const { getDatabase } = await vite.ssrLoadModule('/src/lib/server/database.ts'); - const { auditRecord, recordIsacAudit } = await vite.ssrLoadModule('/src/lib/server/audit.ts'); - const database = await getDatabase(); - const actor = await (await database.prepare(`SELECT users.id FROM users + const { getDatabase } = await vite.ssrLoadModule( + '/src/lib/server/database.ts', + ); + const { auditRecord, recordIsacAudit } = await vite.ssrLoadModule( + '/src/lib/server/audit.ts', + ); + const database = await getDatabase(); + const actor = await ( + await database.prepare(`SELECT users.id FROM users LEFT JOIN user_profiles profile ON profile.user_id=users.id LEFT JOIN user_roles link ON link.user_id=users.id WHERE users.role='admin' OR link.role_id='role-admin' ORDER BY CASE WHEN users.username=? COLLATE NOCASE OR profile.display_name=? COLLATE NOCASE THEN 0 ELSE 1 END,users.created_at - LIMIT 1`)).get('Aewyn', 'Aewyn'); - if (!actor?.id) throw new Error('No administrator account is available to own the Attribute import audit events.'); + LIMIT 1`) + ).get('Aewyn', 'Aewyn'); + if (!actor?.id) + throw new Error( + 'No administrator account is available to own the Attribute import audit events.', + ); - let created = 0; - let updated = 0; - let unchanged = 0; - await database.exec('BEGIN'); - try { - for (const record of records) { - const name = String(record.name).normalize('NFKC').trim(); - const unit = String(record.unit ?? ''); - const existing = await (await database.prepare( - 'SELECT * FROM stat_attributes WHERE name=? COLLATE NOCASE AND unit=?' - )).get(name, unit); - const id = String(existing?.id ?? randomUUID()); - const now = new Date().toISOString(); - const next = { - ...(existing ?? {}), - id, - name, - description: existing?.description ?? null, - type: record.type, - category: record.category, - calculation_type: record.calculationType, - unit, - gear_min: record.gearRange?.[0] ?? existing?.gear_min ?? null, - gear_max: record.gearRange?.[1] ?? existing?.gear_max ?? null, - gear_reference_level: Number(existing?.gear_reference_level ?? 50), - weapon_min: record.weaponRange?.[0] ?? existing?.weapon_min ?? null, - weapon_max: record.weaponRange?.[1] ?? existing?.weapon_max ?? null, - weapon_reference_level: Number(existing?.weapon_reference_level ?? 50), - skill_chip_min: existing?.skill_chip_min ?? null, - skill_chip_max: existing?.skill_chip_max ?? null, - cap_value: existing?.cap_value ?? null, - active: 1, - created_at: String(existing?.created_at ?? now), - updated_at: now - }; - const comparableExisting = existing ? { ...existing, updated_at: now } : null; - if (comparableExisting && isDeepStrictEqual(comparableExisting, next)) { - unchanged++; - continue; - } - const before = existing ? await auditRecord('stat_attributes', id) : null; - if (existing) { - await (await database.prepare(`UPDATE stat_attributes SET + let created = 0; + let updated = 0; + let unchanged = 0; + await database.exec('BEGIN'); + try { + for (const record of records) { + const name = String(record.name).normalize('NFKC').trim(); + const unit = String(record.unit ?? ''); + const existing = await ( + await database.prepare( + 'SELECT * FROM stat_attributes WHERE name=? COLLATE NOCASE AND unit=?', + ) + ).get(name, unit); + const id = String(existing?.id ?? randomUUID()); + const now = new Date().toISOString(); + const next = { + ...(existing ?? {}), + id, + name, + description: existing?.description ?? null, + type: record.type, + category: record.category, + calculation_type: record.calculationType, + unit, + gear_min: record.gearRange?.[0] ?? existing?.gear_min ?? null, + gear_max: record.gearRange?.[1] ?? existing?.gear_max ?? null, + gear_reference_level: Number(existing?.gear_reference_level ?? 50), + weapon_min: record.weaponRange?.[0] ?? existing?.weapon_min ?? null, + weapon_max: record.weaponRange?.[1] ?? existing?.weapon_max ?? null, + weapon_reference_level: Number(existing?.weapon_reference_level ?? 50), + skill_chip_min: existing?.skill_chip_min ?? null, + skill_chip_max: existing?.skill_chip_max ?? null, + cap_value: existing?.cap_value ?? null, + active: 1, + created_at: String(existing?.created_at ?? now), + updated_at: now, + }; + const comparableExisting = existing + ? { ...existing, updated_at: now } + : null; + if (comparableExisting && isDeepStrictEqual(comparableExisting, next)) { + unchanged++; + continue; + } + const before = existing ? await auditRecord('stat_attributes', id) : null; + if (existing) { + await ( + await database.prepare(`UPDATE stat_attributes SET name=?,description=?,type=?,category=?,calculation_type=?,unit=?, gear_min=?,gear_max=?,weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?, - cap_value=?,active=1,updated_at=? WHERE id=?`)).run( - next.name, next.description, next.type, next.category, next.calculation_type, next.unit, - next.gear_min, next.gear_max, next.weapon_min, next.weapon_max, next.skill_chip_min, - next.skill_chip_max, next.cap_value, now, id - ); - updated++; - } else { - await (await database.prepare(`INSERT INTO stat_attributes( + cap_value=?,active=1,updated_at=? WHERE id=?`) + ).run( + next.name, + next.description, + next.type, + next.category, + next.calculation_type, + next.unit, + next.gear_min, + next.gear_max, + next.weapon_min, + next.weapon_max, + next.skill_chip_min, + next.skill_chip_max, + next.cap_value, + now, + id, + ); + updated++; + } else { + await ( + await database.prepare(`INSERT INTO stat_attributes( id,name,description,type,category,calculation_type,unit, gear_min,gear_max,gear_reference_level,weapon_min,weapon_max,weapon_reference_level, skill_chip_min,skill_chip_max,cap_value,active,created_at,updated_at - ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)`)).run( - id, next.name, next.description, next.type, next.category, next.calculation_type, next.unit, - next.gear_min, next.gear_max, next.gear_reference_level, next.weapon_min, next.weapon_max, - next.weapon_reference_level, next.skill_chip_min, next.skill_chip_max, next.cap_value, now, now - ); - created++; - } - await recordIsacAudit({ - actorId: String(actor.id), - category: 'game_data', - action: existing ? 'attribute.updated' : 'attribute.created', - entityId: id, - before, - after: await auditRecord('stat_attributes', id) - }); - } - await database.exec('COMMIT'); - } catch (error) { - await database.exec('ROLLBACK'); - throw error; - } - console.log(`Imported ${records.length} Attribute definitions: ${created} created, ${updated} updated, ${unchanged} unchanged.`); - console.log(`Source: ${sourcePath}`); + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)`) + ).run( + id, + next.name, + next.description, + next.type, + next.category, + next.calculation_type, + next.unit, + next.gear_min, + next.gear_max, + next.gear_reference_level, + next.weapon_min, + next.weapon_max, + next.weapon_reference_level, + next.skill_chip_min, + next.skill_chip_max, + next.cap_value, + now, + now, + ); + created++; + } + await recordIsacAudit({ + actorId: String(actor.id), + category: 'game_data', + action: existing ? 'attribute.updated' : 'attribute.created', + entityId: id, + before, + after: await auditRecord('stat_attributes', id), + }); + } + await database.exec('COMMIT'); + } catch (error) { + await database.exec('ROLLBACK'); + throw error; + } + console.log( + `Imported ${records.length} Attribute definitions: ${created} created, ${updated} updated, ${unchanged} unchanged.`, + ); + console.log(`Source: ${sourcePath}`); } finally { - await vite.close(); + await vite.close(); } diff --git a/scripts/isac-data-export.mjs b/scripts/isac-data-export.mjs index 28311c9..325881e 100644 --- a/scripts/isac-data-export.mjs +++ b/scripts/isac-data-export.mjs @@ -4,49 +4,83 @@ import { basename, dirname, join, resolve } from 'node:path'; import process from 'node:process'; import { ISAC_DATA_TABLES, quoteIdentifier } from './isac-data-tables.mjs'; -const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build-development.db'); -const iconDirectory = resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons'); -const timestamp = new Date().toISOString().replaceAll(':', '-').replace(/\.\d{3}Z$/, 'Z'); -const outputPath = resolve(process.argv[2] || `./data/backups/isac-data-${timestamp}.json`); +const databasePath = resolve( + process.env.TDR_DATABASE_PATH || './data/shd-build-development.db', +); +const iconDirectory = resolve( + process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons', +); +const timestamp = new Date() + .toISOString() + .replaceAll(':', '-') + .replace(/\.\d{3}Z$/, 'Z'); +const outputPath = resolve( + process.argv[2] || `./data/backups/isac-data-${timestamp}.json`, +); const database = await connect(databasePath); const tables = []; let totalRows = 0; for (const name of ISAC_DATA_TABLES) { - const exists = await (await database.prepare( - "SELECT 1 AS present FROM sqlite_master WHERE type='table' AND name=?" - )).get(name); - if (!exists) throw new Error(`ISAC export requires missing table "${name}". Run the schema migration first.`); - const rows = await (await database.prepare(`SELECT * FROM ${quoteIdentifier(name)}`)).all(); - tables.push({ name, rowCount: rows.length, rows }); - totalRows += rows.length; + const exists = await ( + await database.prepare( + "SELECT 1 AS present FROM sqlite_master WHERE type='table' AND name=?", + ) + ).get(name); + if (!exists) + throw new Error( + `ISAC export requires missing table "${name}". Run the schema migration first.`, + ); + const rows = await ( + await database.prepare(`SELECT * FROM ${quoteIdentifier(name)}`) + ).all(); + tables.push({ name, rowCount: rows.length, rows }); + totalRows += rows.length; } const icons = []; const exportedStorageKeys = new Set(); const iconTable = tables.find((table) => table.name === 'game_data_icons'); -const managedImageTable = tables.find((table) => table.name === 'managed_images'); -for (const row of [...(iconTable?.rows ?? []), ...(managedImageTable?.rows ?? [])]) { - if (typeof row.storage_key !== 'string' || !/^[a-z_]+-[0-9a-f-]{36}\.webp$/i.test(row.storage_key)) { - throw new Error(`Invalid managed asset storage key "${String(row.storage_key)}".`); - } - if (exportedStorageKeys.has(row.storage_key)) continue; - exportedStorageKeys.add(row.storage_key); - const content = await readFile(join(iconDirectory, row.storage_key)); - icons.push({ storageKey: row.storage_key, contentBase64: content.toString('base64') }); +const managedImageTable = tables.find( + (table) => table.name === 'managed_images', +); +for (const row of [ + ...(iconTable?.rows ?? []), + ...(managedImageTable?.rows ?? []), +]) { + if ( + typeof row.storage_key !== 'string' || + !/^[a-z_]+-[0-9a-f-]{36}\.webp$/i.test(row.storage_key) + ) { + throw new Error( + `Invalid managed asset storage key "${String(row.storage_key)}".`, + ); + } + if (exportedStorageKeys.has(row.storage_key)) continue; + exportedStorageKeys.add(row.storage_key); + const content = await readFile(join(iconDirectory, row.storage_key)); + icons.push({ + storageKey: row.storage_key, + contentBase64: content.toString('base64'), + }); } const snapshot = { - format: 'shd-build-isac-data', - version: 2, - createdAt: new Date().toISOString(), - sourceDatabase: basename(databasePath), - totalRows, - tables, - icons + format: 'shd-build-isac-data', + version: 2, + createdAt: new Date().toISOString(), + sourceDatabase: basename(databasePath), + totalRows, + tables, + icons, }; await mkdir(dirname(outputPath), { recursive: true }); -await writeFile(outputPath, `${JSON.stringify(snapshot, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' }); +await writeFile(outputPath, `${JSON.stringify(snapshot, null, 2)}\n`, { + encoding: 'utf8', + flag: 'wx', +}); -console.log(`Exported ${totalRows} ISAC rows and ${icons.length} uploaded image assets.`); +console.log( + `Exported ${totalRows} ISAC rows and ${icons.length} uploaded image assets.`, +); console.log(`ISAC snapshot: ${outputPath}`); diff --git a/scripts/isac-data-import.mjs b/scripts/isac-data-import.mjs index 45dcd49..48bd2c9 100644 --- a/scripts/isac-data-import.mjs +++ b/scripts/isac-data-import.mjs @@ -5,39 +5,77 @@ import { basename, join, resolve } from 'node:path'; import process from 'node:process'; import { ISAC_DATA_TABLES, quoteIdentifier } from './isac-data-tables.mjs'; -const snapshotArgument = process.argv.slice(2).find((argument) => !argument.startsWith('--')); -if (!snapshotArgument || !process.argv.includes('--confirm-replace-isac-data')) { - console.error('Usage: node scripts/isac-data-import.mjs --confirm-replace-isac-data'); - process.exit(2); +const snapshotArgument = process.argv + .slice(2) + .find((argument) => !argument.startsWith('--')); +if ( + !snapshotArgument || + !process.argv.includes('--confirm-replace-isac-data') +) { + console.error( + 'Usage: node scripts/isac-data-import.mjs --confirm-replace-isac-data', + ); + process.exit(2); } const snapshotPath = resolve(snapshotArgument); const snapshot = JSON.parse(await readFile(snapshotPath, 'utf8')); -if (snapshot?.format !== 'shd-build-isac-data' || snapshot?.version !== 2 || !Array.isArray(snapshot.tables) || !Array.isArray(snapshot.icons)) { - throw new Error('The selected file is not a supported SHD.build ISAC data snapshot.'); +if ( + snapshot?.format !== 'shd-build-isac-data' || + snapshot?.version !== 2 || + !Array.isArray(snapshot.tables) || + !Array.isArray(snapshot.icons) +) { + throw new Error( + 'The selected file is not a supported SHD.build ISAC data snapshot.', + ); } const receivedNames = snapshot.tables.map((table) => table.name); -if (receivedNames.length !== ISAC_DATA_TABLES.length || ISAC_DATA_TABLES.some((name, index) => receivedNames[index] !== name)) { - throw new Error('The ISAC snapshot table manifest is incomplete or out of order.'); +if ( + receivedNames.length !== ISAC_DATA_TABLES.length || + ISAC_DATA_TABLES.some((name, index) => receivedNames[index] !== name) +) { + throw new Error( + 'The ISAC snapshot table manifest is incomplete or out of order.', + ); } for (const icon of snapshot.icons) { - if (typeof icon.storageKey !== 'string' || !/^[a-z_]+-[0-9a-f-]{36}\.webp$/i.test(icon.storageKey) - || typeof icon.contentBase64 !== 'string') { - throw new Error('The ISAC snapshot contains an invalid uploaded icon.'); - } + if ( + typeof icon.storageKey !== 'string' || + !/^[a-z_]+-[0-9a-f-]{36}\.webp$/i.test(icon.storageKey) || + typeof icon.contentBase64 !== 'string' + ) { + throw new Error('The ISAC snapshot contains an invalid uploaded icon.'); + } } -const timestamp = new Date().toISOString().replaceAll(':', '-').replace(/\.\d{3}Z$/, 'Z'); -const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build-development.db'); -const backupPath = resolve(`./data/backups/${basename(databasePath).replace(/\.[^.]+$/, '')}-before-isac-import-${timestamp}.json`); -const isacBackupPath = resolve(`./data/backups/isac-data-before-import-${timestamp}.json`); +const timestamp = new Date() + .toISOString() + .replaceAll(':', '-') + .replace(/\.\d{3}Z$/, 'Z'); +const databasePath = resolve( + process.env.TDR_DATABASE_PATH || './data/shd-build-development.db', +); +const backupPath = resolve( + `./data/backups/${basename(databasePath).replace(/\.[^.]+$/, '')}-before-isac-import-${timestamp}.json`, +); +const isacBackupPath = resolve( + `./data/backups/isac-data-before-import-${timestamp}.json`, +); for (const [script, output] of [ - ['scripts/db-backup.mjs', backupPath], - ['scripts/isac-data-export.mjs', isacBackupPath] + ['scripts/db-backup.mjs', backupPath], + ['scripts/isac-data-export.mjs', isacBackupPath], ]) { - const result = spawnSync(process.execPath, [script, output], { cwd: process.cwd(), stdio: 'inherit', env: process.env }); - if (result.error) throw result.error; - if (result.status !== 0) throw new Error(`Safety backup failed in ${script}; no ISAC data was changed.`); + const result = spawnSync(process.execPath, [script, output], { + cwd: process.cwd(), + stdio: 'inherit', + env: process.env, + }); + if (result.error) throw result.error; + if (result.status !== 0) + throw new Error( + `Safety backup failed in ${script}; no ISAC data was changed.`, + ); } const database = await connect(databasePath); @@ -45,35 +83,43 @@ let importedRows = 0; await database.exec('PRAGMA foreign_keys=OFF'); await database.exec('BEGIN'); try { - for (const name of [...ISAC_DATA_TABLES].reverse()) { - await database.exec(`DELETE FROM ${quoteIdentifier(name)}`); - } - for (const table of snapshot.tables) { - for (const row of table.rows) { - const columns = Object.keys(row); - if (!columns.length) continue; - const insert = await database.prepare( - `INSERT INTO ${quoteIdentifier(table.name)} (${columns.map(quoteIdentifier).join(', ')}) - VALUES (${columns.map(() => '?').join(', ')})` - ); - await insert.run(...columns.map((column) => row[column])); - importedRows++; - } - } - await database.exec('COMMIT'); + for (const name of [...ISAC_DATA_TABLES].reverse()) { + await database.exec(`DELETE FROM ${quoteIdentifier(name)}`); + } + for (const table of snapshot.tables) { + for (const row of table.rows) { + const columns = Object.keys(row); + if (!columns.length) continue; + const insert = await database.prepare( + `INSERT INTO ${quoteIdentifier(table.name)} (${columns.map(quoteIdentifier).join(', ')}) + VALUES (${columns.map(() => '?').join(', ')})`, + ); + await insert.run(...columns.map((column) => row[column])); + importedRows++; + } + } + await database.exec('COMMIT'); } catch (error) { - await database.exec('ROLLBACK'); - throw error; + await database.exec('ROLLBACK'); + throw error; } finally { - await database.exec('PRAGMA foreign_keys=ON'); + await database.exec('PRAGMA foreign_keys=ON'); } -const iconDirectory = resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons'); +const iconDirectory = resolve( + process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons', +); await mkdir(iconDirectory, { recursive: true }); for (const icon of snapshot.icons) { - await writeFile(join(iconDirectory, icon.storageKey), Buffer.from(icon.contentBase64, 'base64'), { mode: 0o600 }); + await writeFile( + join(iconDirectory, icon.storageKey), + Buffer.from(icon.contentBase64, 'base64'), + { mode: 0o600 }, + ); } -console.log(`Imported ${importedRows} ISAC rows and ${snapshot.icons.length} uploaded image assets from ${snapshotPath}.`); +console.log( + `Imported ${importedRows} ISAC rows and ${snapshot.icons.length} uploaded image assets from ${snapshotPath}.`, +); console.log(`Full pre-import backup: ${backupPath}`); console.log(`ISAC pre-import snapshot: ${isacBackupPath}`); diff --git a/scripts/isac-data-tables.mjs b/scripts/isac-data-tables.mjs index 29f66e9..ab436f3 100644 --- a/scripts/isac-data-tables.mjs +++ b/scripts/isac-data-tables.mjs @@ -1,76 +1,77 @@ export const ISAC_DATA_TABLES = [ - 'stat_attributes', - 'talents', - 'talent_tiers', - 'conditional_effect_rules', - 'conditional_effect_state_pools', - 'conditional_effect_clauses', - 'conditional_effect_outcomes', - 'talent_tier_effects', - 'talent_description_templates', - 'talent_weapon_scopes', - 'weapon_talent_slots', - 'brand_sets', - 'brand_set_effects', - 'gear', - 'gear_brand_sets', - 'gear_talents', - 'gear_tactical_augments', - 'gear_tactical_augment_costs', - 'weapons', - 'core_attribute_tier_ranges', - 'weapon_tier_ranges', - 'weapon_talent_options', - 'weapon_augmentation_effects', - 'weapon_category_attribute_bonuses', - 'os_protocols', - 'os_protocol_primary_effects', - 'os_protocol_secondary_profiles', - 'os_protocol_secondary_effects', - 'os_protocol_talents', - 'os_protocol_talent_effects', - 'skill_chip_sets', - 'skill_chip_two_piece_bonuses', - 'skill_chip_primary_options', - 'skill_chip_secondary_options', - 'skill_chip_talents', - 'skill_chip_talent_effects', - 'weapon_mods', - 'weapon_mod_compatible_categories', - 'weapon_mod_slots', - 'weapon_mod_quality_values', - 'weapon_mod_secondary_options', - 'weapon_mod_secondary_quality_values', - 'weapon_mod_exotic_attributes', - 'weapon_mod_effects', - 'weapon_mod_effect_quality_values', - 'gear_mods', - 'gear_mod_quality_values', - 'gear_mod_secondary_options', - 'gear_mod_secondary_quality_values', - 'specialization_progression_config', - 'specialization_rank_cost_profiles', - 'upgrade_resources', - 'specializations', - 'specialization_combat_effects', - 'specialization_focuses', - 'specialization_abilities', - 'specialization_ability_attacks', - 'specialization_ability_effects', - 'specialization_focus_abilities', - 'specialization_tree_nodes', - 'specialization_tree_node_ranks', - 'specialization_tree_rank_effects', - 'specialization_tree_rank_costs', - 'specialization_tree_rank_combat_effects', - 'specialization_tree_rank_combat_modifiers', - 'calculation_revisions', - 'calculation_stages', - 'attribute_calculation_profiles', - 'skill_attribute_calculation_profiles', - 'calculation_calibration_cases', - 'game_data_icons', - 'managed_images' + 'stat_attributes', + 'talents', + 'talent_tiers', + 'conditional_effect_rules', + 'conditional_effect_state_pools', + 'conditional_effect_clauses', + 'conditional_effect_outcomes', + 'talent_tier_effects', + 'talent_description_templates', + 'talent_weapon_scopes', + 'weapon_talent_slots', + 'brand_sets', + 'brand_set_effects', + 'gear', + 'gear_brand_sets', + 'gear_talents', + 'gear_tactical_augments', + 'gear_tactical_augment_costs', + 'weapons', + 'core_attribute_tier_ranges', + 'weapon_tier_ranges', + 'weapon_talent_options', + 'weapon_augmentation_effects', + 'weapon_category_attribute_bonuses', + 'os_protocols', + 'os_protocol_primary_effects', + 'os_protocol_secondary_profiles', + 'os_protocol_secondary_effects', + 'os_protocol_talents', + 'os_protocol_talent_effects', + 'skill_chip_sets', + 'skill_chip_two_piece_bonuses', + 'skill_chip_primary_options', + 'skill_chip_secondary_options', + 'skill_chip_talents', + 'skill_chip_talent_effects', + 'weapon_mods', + 'weapon_mod_compatible_categories', + 'weapon_mod_slots', + 'weapon_mod_quality_values', + 'weapon_mod_secondary_options', + 'weapon_mod_secondary_quality_values', + 'weapon_mod_exotic_attributes', + 'weapon_mod_effects', + 'weapon_mod_effect_quality_values', + 'gear_mods', + 'gear_mod_quality_values', + 'gear_mod_secondary_options', + 'gear_mod_secondary_quality_values', + 'specialization_progression_config', + 'specialization_rank_cost_profiles', + 'upgrade_resources', + 'specializations', + 'specialization_combat_effects', + 'specialization_focuses', + 'specialization_abilities', + 'specialization_ability_attacks', + 'specialization_ability_effects', + 'specialization_focus_abilities', + 'specialization_tree_nodes', + 'specialization_tree_node_ranks', + 'specialization_tree_rank_effects', + 'specialization_tree_rank_costs', + 'specialization_tree_rank_combat_effects', + 'specialization_tree_rank_combat_modifiers', + 'calculation_revisions', + 'calculation_stages', + 'attribute_calculation_profiles', + 'skill_attribute_calculation_profiles', + 'calculation_calibration_cases', + 'game_data_icons', + 'managed_images', ]; -export const quoteIdentifier = (identifier) => `"${String(identifier).replaceAll('"', '""')}"`; +export const quoteIdentifier = (identifier) => + `"${String(identifier).replaceAll('"', '""')}"`; diff --git a/scripts/managed-images-export.mjs b/scripts/managed-images-export.mjs index 1cb51b4..d60514a 100644 --- a/scripts/managed-images-export.mjs +++ b/scripts/managed-images-export.mjs @@ -2,28 +2,53 @@ import { connect } from '@tursodatabase/database'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { basename, dirname, join, resolve } from 'node:path'; -const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build-development.db'); -const storageDirectory = resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons'); -const timestamp = new Date().toISOString().replaceAll(':', '-').replace(/\.\d{3}Z$/, 'Z'); -const outputPath = resolve(process.argv[2] || `./data/backups/managed-images-${timestamp}.json`); +const databasePath = resolve( + process.env.TDR_DATABASE_PATH || './data/shd-build-development.db', +); +const storageDirectory = resolve( + process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons', +); +const timestamp = new Date() + .toISOString() + .replaceAll(':', '-') + .replace(/\.\d{3}Z$/, 'Z'); +const outputPath = resolve( + process.argv[2] || `./data/backups/managed-images-${timestamp}.json`, +); const database = await connect(databasePath); -const rows = await (await database.prepare('SELECT * FROM managed_images ORDER BY created_at')).all(); +const rows = await ( + await database.prepare('SELECT * FROM managed_images ORDER BY created_at') +).all(); const images = []; for (const row of rows) { - if (typeof row.storage_key !== 'string' - || !/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i.test(row.storage_key)) { - throw new Error(`Invalid managed-image storage key "${String(row.storage_key)}".`); - } - images.push({ row, contentBase64: (await readFile(join(storageDirectory, row.storage_key))).toString('base64') }); + if ( + typeof row.storage_key !== 'string' || + !/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i.test( + row.storage_key, + ) + ) { + throw new Error( + `Invalid managed-image storage key "${String(row.storage_key)}".`, + ); + } + images.push({ + row, + contentBase64: ( + await readFile(join(storageDirectory, row.storage_key)) + ).toString('base64'), + }); } const bundle = { - format: 'shd-build-managed-images', - version: 1, - createdAt: new Date().toISOString(), - sourceDatabase: basename(databasePath), - images + format: 'shd-build-managed-images', + version: 1, + createdAt: new Date().toISOString(), + sourceDatabase: basename(databasePath), + images, }; await mkdir(dirname(outputPath), { recursive: true }); -await writeFile(outputPath, `${JSON.stringify(bundle, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' }); +await writeFile(outputPath, `${JSON.stringify(bundle, null, 2)}\n`, { + encoding: 'utf8', + flag: 'wx', +}); console.log(`Exported ${images.length} managed image assets.`); console.log(`Image bundle: ${outputPath}`); diff --git a/scripts/managed-images-import.mjs b/scripts/managed-images-import.mjs index f9e1422..f9d306c 100644 --- a/scripts/managed-images-import.mjs +++ b/scripts/managed-images-import.mjs @@ -4,47 +4,84 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { join, resolve } from 'node:path'; import { randomUUID } from 'node:crypto'; -const bundleArgument = process.argv.slice(2).find((argument) => !argument.startsWith('--')); +const bundleArgument = process.argv + .slice(2) + .find((argument) => !argument.startsWith('--')); if (!bundleArgument || !process.argv.includes('--confirm-merge')) { - console.error('Usage: node scripts/managed-images-import.mjs --confirm-merge [--update-existing]'); - process.exit(2); + console.error( + 'Usage: node scripts/managed-images-import.mjs --confirm-merge [--update-existing]', + ); + process.exit(2); } const updateExisting = process.argv.includes('--update-existing'); const bundlePath = resolve(bundleArgument); const bundle = JSON.parse(await readFile(bundlePath, 'utf8')); -if (bundle?.format !== 'shd-build-managed-images' || bundle?.version !== 1 || !Array.isArray(bundle.images)) { - throw new Error('The selected file is not a supported SHD.build managed-image bundle.'); +if ( + bundle?.format !== 'shd-build-managed-images' || + bundle?.version !== 1 || + !Array.isArray(bundle.images) +) { + throw new Error( + 'The selected file is not a supported SHD.build managed-image bundle.', + ); } for (const image of bundle.images) { - if (typeof image?.row?.id !== 'string' || !/^[0-9a-f-]{36}$/i.test(image.row.id) - || typeof image.row.storage_key !== 'string' - || !/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i.test(image.row.storage_key) - || typeof image.contentBase64 !== 'string') { - throw new Error('The bundle contains an invalid managed image.'); - } - const isItemRender = ['weapon_render', 'gear_render'].includes(image.row.source_entity_type); - if (image.row.source_entity_type && ( - typeof image.row.source_entity_id !== 'string' - || !/^[0-9a-f-]{36}$/i.test(image.row.source_entity_id) - || (isItemRender - ? !image.row.storage_key.startsWith('library-') - : !image.row.storage_key.startsWith(`${image.row.source_entity_type}-`)) - )) throw new Error('The bundle contains an invalid linked ISAC image.'); + if ( + typeof image?.row?.id !== 'string' || + !/^[0-9a-f-]{36}$/i.test(image.row.id) || + typeof image.row.storage_key !== 'string' || + !/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i.test( + image.row.storage_key, + ) || + typeof image.contentBase64 !== 'string' + ) { + throw new Error('The bundle contains an invalid managed image.'); + } + const isItemRender = ['weapon_render', 'gear_render'].includes( + image.row.source_entity_type, + ); + if ( + image.row.source_entity_type && + (typeof image.row.source_entity_id !== 'string' || + !/^[0-9a-f-]{36}$/i.test(image.row.source_entity_id) || + (isItemRender + ? !image.row.storage_key.startsWith('library-') + : !image.row.storage_key.startsWith( + `${image.row.source_entity_type}-`, + ))) + ) + throw new Error('The bundle contains an invalid linked ISAC image.'); } const backup = spawnSync(process.execPath, ['scripts/db-backup.mjs'], { - cwd: process.cwd(), stdio: 'inherit', env: process.env + cwd: process.cwd(), + stdio: 'inherit', + env: process.env, }); if (backup.error) throw backup.error; -if (backup.status !== 0) throw new Error('Safety backup failed; no managed images were imported.'); -const assetBackup = spawnSync(process.execPath, ['scripts/isac-data-export.mjs'], { - cwd: process.cwd(), stdio: 'inherit', env: process.env -}); +if (backup.status !== 0) + throw new Error('Safety backup failed; no managed images were imported.'); +const assetBackup = spawnSync( + process.execPath, + ['scripts/isac-data-export.mjs'], + { + cwd: process.cwd(), + stdio: 'inherit', + env: process.env, + }, +); if (assetBackup.error) throw assetBackup.error; -if (assetBackup.status !== 0) throw new Error('Uploaded-asset safety snapshot failed; no managed images were imported.'); +if (assetBackup.status !== 0) + throw new Error( + 'Uploaded-asset safety snapshot failed; no managed images were imported.', + ); -const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build-development.db'); -const storageDirectory = resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons'); +const databasePath = resolve( + process.env.TDR_DATABASE_PATH || './data/shd-build-development.db', +); +const storageDirectory = resolve( + process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons', +); const database = await connect(databasePath); await mkdir(storageDirectory, { recursive: true }); let inserted = 0; @@ -53,64 +90,140 @@ let unchanged = 0; const preflight = []; for (const image of bundle.images) { - const row = image.row; - const existing = await (await database.prepare('SELECT * FROM managed_images WHERE id=?')).get(row.id); - if (existing && !updateExisting) { - const same = ['name','category','tags_json','is_icon','force_white','storage_key','byte_size','width','height','source_entity_type','source_entity_id'] - .every((key) => String(existing[key]) === String(row[key])); - if (!same) throw new Error(`Image ${row.id} already exists with different metadata. Re-run with --update-existing after review.`); - unchanged++; - continue; - } - const creator = await (await database.prepare('SELECT id FROM users WHERE id=?')).get(row.created_by); - if (!creator) throw new Error(`Image "${row.name}" references a creator that does not exist in the target database.`); - const nameConflict = await (await database.prepare('SELECT id FROM managed_images WHERE name=? COLLATE NOCASE AND id<>?')).get(row.name, row.id); - if (nameConflict) throw new Error(`Image name "${row.name}" is already assigned to a different target asset.`); - if (row.source_entity_type && row.source_entity_id) { - const sourceConflict = await (await database.prepare( - 'SELECT id FROM managed_images WHERE source_entity_type=? AND source_entity_id=? AND id<>?' - )).get(row.source_entity_type, row.source_entity_id, row.id); - if (sourceConflict) throw new Error(`The ${row.source_entity_type} image ${row.source_entity_id} is already registered under a different library asset.`); - } - preflight.push({ image, existing }); + const row = image.row; + const existing = await ( + await database.prepare('SELECT * FROM managed_images WHERE id=?') + ).get(row.id); + if (existing && !updateExisting) { + const same = [ + 'name', + 'category', + 'tags_json', + 'is_icon', + 'force_white', + 'storage_key', + 'byte_size', + 'width', + 'height', + 'source_entity_type', + 'source_entity_id', + ].every((key) => String(existing[key]) === String(row[key])); + if (!same) + throw new Error( + `Image ${row.id} already exists with different metadata. Re-run with --update-existing after review.`, + ); + unchanged++; + continue; + } + const creator = await ( + await database.prepare('SELECT id FROM users WHERE id=?') + ).get(row.created_by); + if (!creator) + throw new Error( + `Image "${row.name}" references a creator that does not exist in the target database.`, + ); + const nameConflict = await ( + await database.prepare( + 'SELECT id FROM managed_images WHERE name=? COLLATE NOCASE AND id<>?', + ) + ).get(row.name, row.id); + if (nameConflict) + throw new Error( + `Image name "${row.name}" is already assigned to a different target asset.`, + ); + if (row.source_entity_type && row.source_entity_id) { + const sourceConflict = await ( + await database.prepare( + 'SELECT id FROM managed_images WHERE source_entity_type=? AND source_entity_id=? AND id<>?', + ) + ).get(row.source_entity_type, row.source_entity_id, row.id); + if (sourceConflict) + throw new Error( + `The ${row.source_entity_type} image ${row.source_entity_id} is already registered under a different library asset.`, + ); + } + preflight.push({ image, existing }); } await database.exec('BEGIN'); try { -for (const { image, existing } of preflight) { - const row = image.row; - await writeFile(join(storageDirectory, row.storage_key), Buffer.from(image.contentBase64, 'base64'), { - mode: 0o600, flag: existing || row.source_entity_type ? 'w' : 'wx' - }); - if (existing) { - await (await database.prepare(`UPDATE managed_images SET + for (const { image, existing } of preflight) { + const row = image.row; + await writeFile( + join(storageDirectory, row.storage_key), + Buffer.from(image.contentBase64, 'base64'), + { + mode: 0o600, + flag: existing || row.source_entity_type ? 'w' : 'wx', + }, + ); + if (existing) { + await ( + await database.prepare(`UPDATE managed_images SET name=?,category=?,tags_json=?,is_icon=?,force_white=?,storage_key=?,original_filename=?, - byte_size=?,width=?,height=?,source_entity_type=?,source_entity_id=?,updated_at=? WHERE id=?`)).run( - row.name,row.category,row.tags_json,row.is_icon,row.force_white,row.storage_key,row.original_filename, - row.byte_size,row.width,row.height,row.source_entity_type ?? null,row.source_entity_id ?? null,row.updated_at,row.id - ); - updated++; - } else { - await (await database.prepare(`INSERT INTO managed_images + byte_size=?,width=?,height=?,source_entity_type=?,source_entity_id=?,updated_at=? WHERE id=?`) + ).run( + row.name, + row.category, + row.tags_json, + row.is_icon, + row.force_white, + row.storage_key, + row.original_filename, + row.byte_size, + row.width, + row.height, + row.source_entity_type ?? null, + row.source_entity_id ?? null, + row.updated_at, + row.id, + ); + updated++; + } else { + await ( + await database.prepare(`INSERT INTO managed_images (id,name,category,tags_json,is_icon,force_white,storage_key,original_filename,byte_size,width,height, source_entity_type,source_entity_id,created_by,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run( - row.id,row.name,row.category,row.tags_json,row.is_icon,row.force_white,row.storage_key,row.original_filename, - row.byte_size,row.width,row.height,row.source_entity_type ?? null,row.source_entity_id ?? null, - row.created_by,row.created_at,row.updated_at - ); - inserted++; - } - await (await database.prepare(`INSERT INTO audit_log - (id,actor_id,entity_type,entity_id,action,before_json,after_json,created_at) VALUES(?,?,?,?,?,?,?,?)`)).run( - randomUUID(), row.created_by, 'system_configuration', row.id, - existing ? 'image.import_updated' : 'image.imported', - existing ? JSON.stringify(existing) : null, JSON.stringify(row), new Date().toISOString() - ); -} -await database.exec('COMMIT'); + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`) + ).run( + row.id, + row.name, + row.category, + row.tags_json, + row.is_icon, + row.force_white, + row.storage_key, + row.original_filename, + row.byte_size, + row.width, + row.height, + row.source_entity_type ?? null, + row.source_entity_id ?? null, + row.created_by, + row.created_at, + row.updated_at, + ); + inserted++; + } + await ( + await database.prepare(`INSERT INTO audit_log + (id,actor_id,entity_type,entity_id,action,before_json,after_json,created_at) VALUES(?,?,?,?,?,?,?,?)`) + ).run( + randomUUID(), + row.created_by, + 'system_configuration', + row.id, + existing ? 'image.import_updated' : 'image.imported', + existing ? JSON.stringify(existing) : null, + JSON.stringify(row), + new Date().toISOString(), + ); + } + await database.exec('COMMIT'); } catch (error) { - await database.exec('ROLLBACK'); - throw error; + await database.exec('ROLLBACK'); + throw error; } -console.log(`Managed-image merge complete: ${inserted} inserted, ${updated} updated, ${unchanged} unchanged.`); +console.log( + `Managed-image merge complete: ${inserted} inserted, ${updated} updated, ${unchanged} unchanged.`, +); diff --git a/scripts/notification-digest.mjs b/scripts/notification-digest.mjs index f919ebc..6a124e4 100644 --- a/scripts/notification-digest.mjs +++ b/scripts/notification-digest.mjs @@ -2,40 +2,78 @@ import { connect } from '@tursodatabase/database'; import nodemailer from 'nodemailer'; import { resolve } from 'node:path'; -if (!process.env.SMTP_HOST || !process.env.SMTP_FROM) throw new Error('SMTP delivery is not configured.'); -const database = await connect(resolve(process.env.TDR_DATABASE_PATH || './data/shd-build.db')); +if (!process.env.SMTP_HOST || !process.env.SMTP_FROM) + throw new Error('SMTP delivery is not configured.'); +const database = await connect( + resolve(process.env.TDR_DATABASE_PATH || './data/shd-build.db'), +); const transport = nodemailer.createTransport({ - host: process.env.SMTP_HOST, - port: Number(process.env.SMTP_PORT || 587), - secure: process.env.SMTP_SECURE === 'true', - auth: process.env.SMTP_USER ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD } : undefined, - requireTLS: process.env.SMTP_SECURE !== 'true', - disableFileAccess: true, - disableUrlAccess: true + host: process.env.SMTP_HOST, + port: Number(process.env.SMTP_PORT || 587), + secure: process.env.SMTP_SECURE === 'true', + auth: process.env.SMTP_USER + ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD } + : undefined, + requireTLS: process.env.SMTP_SECURE !== 'true', + disableFileAccess: true, + disableUrlAccess: true, }); -const origin = (process.env.APP_ORIGIN || 'http://localhost:5173').replace(/\/+$/, ''); -const recipients = await (await database.prepare(`SELECT DISTINCT n.user_id,e.email +const origin = (process.env.APP_ORIGIN || 'http://localhost:5173').replace( + /\/+$/, + '', +); +const recipients = await ( + await database.prepare(`SELECT DISTINCT n.user_id,e.email FROM notifications n JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category AND p.email_frequency='daily' JOIN user_emails e ON e.user_id=n.user_id - WHERE n.emailed_at IS NULL AND n.created_at>=p.updated_at`)).all(); + WHERE n.emailed_at IS NULL AND n.created_at>=p.updated_at`) +).all(); let sent = 0; for (const recipient of recipients) { - const items = await (await database.prepare(`SELECT n.id,n.title,n.body,n.link FROM notifications n + const items = await ( + await database.prepare(`SELECT n.id,n.title,n.body,n.link FROM notifications n JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category AND p.email_frequency='daily' - WHERE n.user_id=? AND n.emailed_at IS NULL AND n.created_at>=p.updated_at ORDER BY n.created_at LIMIT 100`)).all(recipient.user_id); - if (!items.length) continue; - const escape = (value) => String(value).replace(/[&<>"']/g, (character) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character]); - const rows = items.map((item) => `
${escape(item.title)}

${escape(item.body)}

${item.link ? `VIEW INTELLIGENCE` : ''}
`).join(''); - await transport.sendMail({ - from: process.env.SMTP_FROM, - to: recipient.email, - subject: `SHD.build daily notification summary · ${items.length} update${items.length === 1 ? '' : 's'}`, - text: items.map((item) => `${item.title}\n${item.body}\n${item.link ? `${origin}${item.link}` : ''}`).join('\n\n'), - html: `

SHD DAILY INTELLIGENCE

${items.length} notification${items.length === 1 ? '' : 's'}

${rows}
` - }); - const now = new Date().toISOString(); - for (const item of items) await (await database.prepare('UPDATE notifications SET emailed_at=? WHERE id=? AND emailed_at IS NULL')).run(now, item.id); - sent += 1; + WHERE n.user_id=? AND n.emailed_at IS NULL AND n.created_at>=p.updated_at ORDER BY n.created_at LIMIT 100`) + ).all(recipient.user_id); + if (!items.length) continue; + const escape = (value) => + String(value).replace( + /[&<>"']/g, + (character) => + ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + })[character], + ); + const rows = items + .map( + (item) => + `
${escape(item.title)}

${escape(item.body)}

${item.link ? `VIEW INTELLIGENCE` : ''}
`, + ) + .join(''); + await transport.sendMail({ + from: process.env.SMTP_FROM, + to: recipient.email, + subject: `SHD.build daily notification summary · ${items.length} update${items.length === 1 ? '' : 's'}`, + text: items + .map( + (item) => + `${item.title}\n${item.body}\n${item.link ? `${origin}${item.link}` : ''}`, + ) + .join('\n\n'), + html: `

SHD DAILY INTELLIGENCE

${items.length} notification${items.length === 1 ? '' : 's'}

${rows}
`, + }); + const now = new Date().toISOString(); + for (const item of items) + await ( + await database.prepare( + 'UPDATE notifications SET emailed_at=? WHERE id=? AND emailed_at IS NULL', + ) + ).run(now, item.id); + sent += 1; } console.log(`Sent ${sent} daily notification digest${sent === 1 ? '' : 's'}.`); diff --git a/scripts/oversight-notification-dispatch.mjs b/scripts/oversight-notification-dispatch.mjs index f22b648..a72edbe 100644 --- a/scripts/oversight-notification-dispatch.mjs +++ b/scripts/oversight-notification-dispatch.mjs @@ -3,67 +3,100 @@ import nodemailer from 'nodemailer'; import { randomUUID } from 'node:crypto'; import { resolve } from 'node:path'; -const database = await connect(resolve(process.env.TDR_DATABASE_PATH || './data/shd-build.db')); +const database = await connect( + resolve(process.env.TDR_DATABASE_PATH || './data/shd-build.db'), +); const now = new Date().toISOString(); -const due = await (await database.prepare(`SELECT * FROM oversight_notification_queue - WHERE delivered_at IS NULL AND deliver_after<=? ORDER BY user_id,oversight_category,created_at`)).all(now); +const due = await ( + await database.prepare(`SELECT * FROM oversight_notification_queue + WHERE delivered_at IS NULL AND deliver_after<=? ORDER BY user_id,oversight_category,created_at`) +).all(now); const groups = new Map(); for (const item of due) { - const key = `${item.user_id}:${item.oversight_category}:${item.frequency}:${item.deliver_after}`; - if (!groups.has(key)) groups.set(key, []); - groups.get(key).push(item); + const key = `${item.user_id}:${item.oversight_category}:${item.frequency}:${item.deliver_after}`; + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(item); } const smtpConfigured = Boolean(process.env.SMTP_HOST && process.env.SMTP_FROM); -const transport = smtpConfigured ? nodemailer.createTransport({ - host: process.env.SMTP_HOST, - port: Number(process.env.SMTP_PORT || 587), - secure: process.env.SMTP_SECURE === 'true', - auth: process.env.SMTP_USER ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD } : undefined, - requireTLS: process.env.SMTP_SECURE !== 'true', - disableFileAccess: true, - disableUrlAccess: true -}) : null; -const origin = (process.env.APP_ORIGIN || 'http://localhost:5173').replace(/\/+$/, ''); +const transport = smtpConfigured + ? nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: Number(process.env.SMTP_PORT || 587), + secure: process.env.SMTP_SECURE === 'true', + auth: process.env.SMTP_USER + ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD } + : undefined, + requireTLS: process.env.SMTP_SECURE !== 'true', + disableFileAccess: true, + disableUrlAccess: true, + }) + : null; +const origin = (process.env.APP_ORIGIN || 'http://localhost:5173').replace( + /\/+$/, + '', +); let delivered = 0; for (const items of groups.values()) { - const first = items[0]; - const categoryLabel = first.oversight_category === 'avatars' ? 'avatar' : 'data change'; - const title = `${items.length} ${categoryLabel} review${items.length === 1 ? '' : 's'} awaiting Oversight`; - const details = items.slice(0, 10).map((item) => `• ${item.summary}`).join('\n'); - const remainder = items.length > 10 ? `\n• ${items.length - 10} additional review items` : ''; - const body = `${details}${remainder}`; - const notificationId = randomUUID(); - await (await database.prepare(`INSERT INTO notifications + const first = items[0]; + const categoryLabel = + first.oversight_category === 'avatars' ? 'avatar' : 'data change'; + const title = `${items.length} ${categoryLabel} review${items.length === 1 ? '' : 's'} awaiting Oversight`; + const details = items + .slice(0, 10) + .map((item) => `• ${item.summary}`) + .join('\n'); + const remainder = + items.length > 10 ? `\n• ${items.length - 10} additional review items` : ''; + const body = `${details}${remainder}`; + const notificationId = randomUUID(); + await ( + await database.prepare(`INSERT INTO notifications (id,user_id,category,source,title,body,link,mandatory,created_at) - VALUES(?,?,'isac','ISAC OVERSIGHT NETWORK',?,?,?,1,?)`)).run( - notificationId, first.user_id, title, body, first.link, now - ); - for (const item of items) { - await (await database.prepare('UPDATE oversight_notification_queue SET delivered_at=? WHERE id=? AND delivered_at IS NULL')).run(now, item.id); - } - const preference = await (await database.prepare( - "SELECT email_frequency FROM notification_preferences WHERE user_id=? AND category='isac'" - )).get(first.user_id); - if (transport && preference?.email_frequency === 'immediate') { - const identity = await (await database.prepare('SELECT email FROM user_emails WHERE user_id=?')).get(first.user_id); - if (typeof identity?.email === 'string') { - try { - await transport.sendMail({ - from: process.env.SMTP_FROM, - to: identity.email, - subject: `SHD.build · ${title}`, - text: `${body}\n\n${origin}${first.link}`, - html: `

ISAC OVERSIGHT NETWORK

${title}

${body}

OPEN OVERSIGHT QUEUE
` - }); - await (await database.prepare('UPDATE notifications SET emailed_at=? WHERE id=?')).run(now, notificationId); - } catch (error) { - console.error(`Unable to email Oversight digest ${notificationId}`, error); - } - } - } - delivered += items.length; + VALUES(?,?,'isac','ISAC OVERSIGHT NETWORK',?,?,?,1,?)`) + ).run(notificationId, first.user_id, title, body, first.link, now); + for (const item of items) { + await ( + await database.prepare( + 'UPDATE oversight_notification_queue SET delivered_at=? WHERE id=? AND delivered_at IS NULL', + ) + ).run(now, item.id); + } + const preference = await ( + await database.prepare( + "SELECT email_frequency FROM notification_preferences WHERE user_id=? AND category='isac'", + ) + ).get(first.user_id); + if (transport && preference?.email_frequency === 'immediate') { + const identity = await ( + await database.prepare('SELECT email FROM user_emails WHERE user_id=?') + ).get(first.user_id); + if (typeof identity?.email === 'string') { + try { + await transport.sendMail({ + from: process.env.SMTP_FROM, + to: identity.email, + subject: `SHD.build · ${title}`, + text: `${body}\n\n${origin}${first.link}`, + html: `

ISAC OVERSIGHT NETWORK

${title}

${body}

OPEN OVERSIGHT QUEUE
`, + }); + await ( + await database.prepare( + 'UPDATE notifications SET emailed_at=? WHERE id=?', + ) + ).run(now, notificationId); + } catch (error) { + console.error( + `Unable to email Oversight digest ${notificationId}`, + error, + ); + } + } + } + delivered += items.length; } -console.log(`Delivered ${delivered} queued Oversight review alert${delivered === 1 ? '' : 's'} in ${groups.size} digest${groups.size === 1 ? '' : 's'}.`); +console.log( + `Delivered ${delivered} queued Oversight review alert${delivered === 1 ? '' : 's'} in ${groups.size} digest${groups.size === 1 ? '' : 's'}.`, +); diff --git a/scripts/render-brand-assets.mjs b/scripts/render-brand-assets.mjs index 16a9695..404c334 100644 --- a/scripts/render-brand-assets.mjs +++ b/scripts/render-brand-assets.mjs @@ -6,35 +6,47 @@ import sharp from 'sharp'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const outputDirectory = path.join(root, 'static', 'brand'); -const fontDirectory = path.join(root, 'node_modules', '@fontsource', 'barlow', 'files'); -const heavy = opentype.parse(fs.readFileSync(path.join(fontDirectory, 'barlow-latin-900-normal.woff')).buffer); -const medium = opentype.parse(fs.readFileSync(path.join(fontDirectory, 'barlow-latin-700-normal.woff')).buffer); +const fontDirectory = path.join( + root, + 'node_modules', + '@fontsource', + 'barlow', + 'files', +); +const heavy = opentype.parse( + fs.readFileSync(path.join(fontDirectory, 'barlow-latin-900-normal.woff')) + .buffer, +); +const medium = opentype.parse( + fs.readFileSync(path.join(fontDirectory, 'barlow-latin-700-normal.woff')) + .buffer, +); fs.mkdirSync(outputDirectory, { recursive: true }); const colors = { - background: '#0a0c0d', - surface: '#111415', - border: '#303536', - grid: '#2a3031', - orange: '#f6a821', - white: '#ecebe5' + background: '#0a0c0d', + surface: '#111415', + border: '#303536', + grid: '#2a3031', + orange: '#f6a821', + white: '#ecebe5', }; function trackedPath(font, value, x, baseline, size, tracking = 0) { - let cursor = x; - let data = ''; - for (const character of value) { - const glyph = font.charToGlyph(character); - data += glyph.getPath(cursor, baseline, size).toPathData(2); - cursor += glyph.advanceWidth * size / font.unitsPerEm + tracking; - } - return { data, width: cursor - x - tracking }; + let cursor = x; + let data = ''; + for (const character of value) { + const glyph = font.charToGlyph(character); + data += glyph.getPath(cursor, baseline, size).toPathData(2); + cursor += (glyph.advanceWidth * size) / font.unitsPerEm + tracking; + } + return { data, width: cursor - x - tracking }; } function emblem(x, y, size) { - const scale = size / 100; - return ` + const scale = size / 100; + return ` @@ -47,10 +59,10 @@ function emblem(x, y, size) { } async function writePng(svg, filename, width, height) { - await sharp(Buffer.from(svg)) - .resize(width, height, { fit: 'fill' }) - .png({ compressionLevel: 9, adaptiveFiltering: true }) - .toFile(path.join(outputDirectory, filename)); + await sharp(Buffer.from(svg)) + .resize(width, height, { fit: 'fill' }) + .png({ compressionLevel: 9, adaptiveFiltering: true }) + .toFile(path.join(outputDirectory, filename)); } const transparentLogo = ` @@ -154,10 +166,17 @@ const inviteSplash = ` await writePng(transparentLogo, 'shd-core-segments.png', 1024, 1024); await writePng(serverIcon, 'shd-build-discord-icon.png', 1024, 1024); await writePng(banner, 'shd-build-discord-banner-1920x1080.png', 1920, 1080); -await writePng(inviteSplash, 'shd-build-discord-invite-background-1920x1080.png', 1920, 1080); -await sharp(path.join(outputDirectory, 'shd-build-discord-banner-1920x1080.png')) - .resize(960, 540) - .png({ compressionLevel: 9, adaptiveFiltering: true }) - .toFile(path.join(outputDirectory, 'shd-build-discord-banner-960x540.png')); +await writePng( + inviteSplash, + 'shd-build-discord-invite-background-1920x1080.png', + 1920, + 1080, +); +await sharp( + path.join(outputDirectory, 'shd-build-discord-banner-1920x1080.png'), +) + .resize(960, 540) + .png({ compressionLevel: 9, adaptiveFiltering: true }) + .toFile(path.join(outputDirectory, 'shd-build-discord-banner-960x540.png')); console.log(`Rendered brand assets to ${outputDirectory}`); diff --git a/scripts/scheduler-client.mjs b/scripts/scheduler-client.mjs index 38fa1c8..4037f98 100644 --- a/scripts/scheduler-client.mjs +++ b/scripts/scheduler-client.mjs @@ -1,17 +1,19 @@ const endpoint = process.env.SCHEDULER_URL; const token = process.env.SCHEDULER_TOKEN; -if (!endpoint || !token) throw new Error('SCHEDULER_URL and SCHEDULER_TOKEN are required.'); +if (!endpoint || !token) + throw new Error('SCHEDULER_URL and SCHEDULER_TOKEN are required.'); while (true) { - try { - const response = await fetch(endpoint, { - method: 'POST', - headers: { authorization: `Bearer ${token}` } - }); - if (!response.ok) throw new Error(`Scheduler endpoint returned HTTP ${response.status}.`); - console.log(`Scheduler tick: ${JSON.stringify(await response.json())}`); - } catch (error) { - console.error('Scheduler tick failed', error); - } - await new Promise((resolve) => setTimeout(resolve, 60_000)); + try { + const response = await fetch(endpoint, { + method: 'POST', + headers: { authorization: `Bearer ${token}` }, + }); + if (!response.ok) + throw new Error(`Scheduler endpoint returned HTTP ${response.status}.`); + console.log(`Scheduler tick: ${JSON.stringify(await response.json())}`); + } catch (error) { + console.error('Scheduler tick failed', error); + } + await new Promise((resolve) => setTimeout(resolve, 60_000)); } diff --git a/scripts/seed-initial-users.mjs b/scripts/seed-initial-users.mjs index db9fef6..61ca610 100644 --- a/scripts/seed-initial-users.mjs +++ b/scripts/seed-initial-users.mjs @@ -3,7 +3,9 @@ import { resolve } from 'node:path'; import process from 'node:process'; import { initializeDefaultUsers } from './default-users.mjs'; -const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build-development.db'); +const databasePath = resolve( + process.env.TDR_DATABASE_PATH || './data/shd-build-development.db', +); const database = await connect(databasePath); const resetPasswords = process.argv.includes('--reset-passwords'); diff --git a/scripts/seed-test-notifications.mjs b/scripts/seed-test-notifications.mjs index ae45a07..17a86b0 100644 --- a/scripts/seed-test-notifications.mjs +++ b/scripts/seed-test-notifications.mjs @@ -3,22 +3,63 @@ import { randomUUID } from 'node:crypto'; import { resolve } from 'node:path'; const targetName = process.argv[2] || 'Aewyn'; -const database = await connect(resolve(process.env.TDR_DATABASE_PATH || './data/shd-build.db')); -const user = await (await database.prepare(`SELECT users.id,COALESCE(user_profiles.display_name,users.username) AS display_name +const database = await connect( + resolve(process.env.TDR_DATABASE_PATH || './data/shd-build.db'), +); +const user = await ( + await database.prepare(`SELECT users.id,COALESCE(user_profiles.display_name,users.username) AS display_name FROM users LEFT JOIN user_profiles ON user_profiles.user_id=users.id WHERE users.login_username=? COLLATE NOCASE OR users.username=? COLLATE NOCASE OR user_profiles.display_name=? COLLATE NOCASE - LIMIT 1`)).get(targetName, targetName, targetName); + LIMIT 1`) +).get(targetName, targetName, targetName); if (!user) throw new Error(`No user matched ${targetName}.`); const now = Date.now(); const samples = [ - ['isac', 'ISAC AVATAR OVERSIGHT', '[TEST] Custom avatar removed', 'Your custom avatar did not meet SHD.build image requirements and was removed. Oversight reason: The image contained unrelated promotional text.', '/account#avatar', 1], - ['contributions', 'ISAC DATA OVERSIGHT', '[TEST] Contribution approved', 'Quick Draw brand-set data was approved and deployed to the live builder registry.', '/isac/brands', 1], - ['contributions', 'ISAC DATA OVERSIGHT', '[TEST] Contribution needs revision', 'A talent update was not approved. Oversight reason: Tier II effect value does not match the supplied game evidence.', '/isac/talents', 1], - ['comments', 'SHD COMMUNITY', '[TEST] New comment on your build', 'An agent commented on “First Wave Vector Critical Build”: This setup worked really well in Legendary missions.', '/builds', 0] + [ + 'isac', + 'ISAC AVATAR OVERSIGHT', + '[TEST] Custom avatar removed', + 'Your custom avatar did not meet SHD.build image requirements and was removed. Oversight reason: The image contained unrelated promotional text.', + '/account#avatar', + 1, + ], + [ + 'contributions', + 'ISAC DATA OVERSIGHT', + '[TEST] Contribution approved', + 'Quick Draw brand-set data was approved and deployed to the live builder registry.', + '/isac/brands', + 1, + ], + [ + 'contributions', + 'ISAC DATA OVERSIGHT', + '[TEST] Contribution needs revision', + 'A talent update was not approved. Oversight reason: Tier II effect value does not match the supplied game evidence.', + '/isac/talents', + 1, + ], + [ + 'comments', + 'SHD COMMUNITY', + '[TEST] New comment on your build', + 'An agent commented on “First Wave Vector Critical Build”: This setup worked really well in Legendary missions.', + '/builds', + 0, + ], ]; for (const [index, sample] of samples.entries()) { - await (await database.prepare(`INSERT INTO notifications + await ( + await database.prepare(`INSERT INTO notifications (id,user_id,category,source,title,body,link,mandatory,created_at) - VALUES(?,?,?,?,?,?,?,?,?)`)).run(randomUUID(), user.id, ...sample, new Date(now - index * 60_000).toISOString()); + VALUES(?,?,?,?,?,?,?,?,?)`) + ).run( + randomUUID(), + user.id, + ...sample, + new Date(now - index * 60_000).toISOString(), + ); } -console.log(`Added ${samples.length} test notifications for ${user.display_name}.`); +console.log( + `Added ${samples.length} test notifications for ${user.display_name}.`, +); diff --git a/scripts/stage-bootstrap-export.mjs b/scripts/stage-bootstrap-export.mjs index dfe08a3..91dd54f 100644 --- a/scripts/stage-bootstrap-export.mjs +++ b/scripts/stage-bootstrap-export.mjs @@ -4,51 +4,74 @@ import { dirname, join, resolve } from 'node:path'; import process from 'node:process'; const outputArgument = process.argv[2]; -const timestamp = new Date().toISOString().replaceAll(':', '-').replace(/\.\d{3}Z$/, 'Z'); -const outputPath = resolve(outputArgument || `./data/backups/shd-stage-bootstrap-${timestamp}.json`); +const timestamp = new Date() + .toISOString() + .replaceAll(':', '-') + .replace(/\.\d{3}Z$/, 'Z'); +const outputPath = resolve( + outputArgument || `./data/backups/shd-stage-bootstrap-${timestamp}.json`, +); const temporaryBackupPath = `${outputPath}.database.json`; -const databaseBackup = spawnSync(process.execPath, ['scripts/db-backup.mjs', temporaryBackupPath], { - cwd: process.cwd(), - stdio: 'inherit', - env: process.env -}); +const databaseBackup = spawnSync( + process.execPath, + ['scripts/db-backup.mjs', temporaryBackupPath], + { + cwd: process.cwd(), + stdio: 'inherit', + env: process.env, + }, +); if (databaseBackup.error) throw databaseBackup.error; -if (databaseBackup.status !== 0) throw new Error('Database backup failed; no staging bootstrap bundle was created.'); +if (databaseBackup.status !== 0) + throw new Error( + 'Database backup failed; no staging bootstrap bundle was created.', + ); async function collectFiles(directory, pattern) { - try { - const names = (await readdir(directory)).filter((name) => pattern.test(name)).sort(); - return await Promise.all(names.map(async (name) => ({ - name, - contentBase64: (await readFile(join(directory, name))).toString('base64') - }))); - } catch (error) { - if (error?.code === 'ENOENT') return []; - throw error; - } + try { + const names = (await readdir(directory)) + .filter((name) => pattern.test(name)) + .sort(); + return await Promise.all( + names.map(async (name) => ({ + name, + contentBase64: (await readFile(join(directory, name))).toString( + 'base64', + ), + })), + ); + } catch (error) { + if (error?.code === 'ENOENT') return []; + throw error; + } } try { - const database = JSON.parse(await readFile(temporaryBackupPath, 'utf8')); - const avatars = await collectFiles( - resolve(process.env.TDR_AVATAR_STORAGE_PATH || './data/avatars'), - /^[0-9a-f-]{36}\.webp$/i - ); - const gameIcons = await collectFiles( - resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons'), - /^[a-z_]+-[0-9a-f-]{36}\.webp$/i - ); - const bundle = { - format: 'shd-build-stage-bootstrap', - version: 1, - createdAt: new Date().toISOString(), - database, - files: { avatars, gameIcons } - }; - await mkdir(dirname(outputPath), { recursive: true }); - await writeFile(outputPath, `${JSON.stringify(bundle, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' }); - console.log(`Staging bootstrap bundle includes ${avatars.length} avatars and ${gameIcons.length} game-data icons.`); - console.log(`Bootstrap bundle: ${outputPath}`); + const database = JSON.parse(await readFile(temporaryBackupPath, 'utf8')); + const avatars = await collectFiles( + resolve(process.env.TDR_AVATAR_STORAGE_PATH || './data/avatars'), + /^[0-9a-f-]{36}\.webp$/i, + ); + const gameIcons = await collectFiles( + resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons'), + /^[a-z_]+-[0-9a-f-]{36}\.webp$/i, + ); + const bundle = { + format: 'shd-build-stage-bootstrap', + version: 1, + createdAt: new Date().toISOString(), + database, + files: { avatars, gameIcons }, + }; + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, `${JSON.stringify(bundle, null, 2)}\n`, { + encoding: 'utf8', + flag: 'wx', + }); + console.log( + `Staging bootstrap bundle includes ${avatars.length} avatars and ${gameIcons.length} game-data icons.`, + ); + console.log(`Bootstrap bundle: ${outputPath}`); } finally { - await rm(temporaryBackupPath, { force: true }); + await rm(temporaryBackupPath, { force: true }); } diff --git a/scripts/stage-bootstrap-import.mjs b/scripts/stage-bootstrap-import.mjs index 5038b63..c250e8f 100644 --- a/scripts/stage-bootstrap-import.mjs +++ b/scripts/stage-bootstrap-import.mjs @@ -4,106 +4,165 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { join, resolve } from 'node:path'; import process from 'node:process'; -const bundleArgument = process.argv.slice(2).find((argument) => !argument.startsWith('--')); -if (!bundleArgument || !process.argv.includes('--confirm-empty-staging-replace') - || process.env.ALLOW_STAGING_BOOTSTRAP !== 'true') { - console.error('Staging bootstrap requires ALLOW_STAGING_BOOTSTRAP=true and:'); - console.error('node scripts/stage-bootstrap-import.mjs --confirm-empty-staging-replace'); - process.exit(2); +const bundleArgument = process.argv + .slice(2) + .find((argument) => !argument.startsWith('--')); +if ( + !bundleArgument || + !process.argv.includes('--confirm-empty-staging-replace') || + process.env.ALLOW_STAGING_BOOTSTRAP !== 'true' +) { + console.error('Staging bootstrap requires ALLOW_STAGING_BOOTSTRAP=true and:'); + console.error( + 'node scripts/stage-bootstrap-import.mjs --confirm-empty-staging-replace', + ); + process.exit(2); } const bundlePath = resolve(bundleArgument); const bundle = JSON.parse(await readFile(bundlePath, 'utf8')); -if (bundle?.format !== 'shd-build-stage-bootstrap' || bundle?.version !== 1 - || bundle.database?.format !== 'shd-build-database-backup' || bundle.database?.version !== 1 - || !Array.isArray(bundle.database.tables) || !Array.isArray(bundle.files?.avatars) - || !Array.isArray(bundle.files?.gameIcons)) { - throw new Error('The selected file is not a supported SHD.build staging bootstrap bundle.'); +if ( + bundle?.format !== 'shd-build-stage-bootstrap' || + bundle?.version !== 1 || + bundle.database?.format !== 'shd-build-database-backup' || + bundle.database?.version !== 1 || + !Array.isArray(bundle.database.tables) || + !Array.isArray(bundle.files?.avatars) || + !Array.isArray(bundle.files?.gameIcons) +) { + throw new Error( + 'The selected file is not a supported SHD.build staging bootstrap bundle.', + ); } for (const file of bundle.files.avatars) { - if (typeof file.name !== 'string' || !/^[0-9a-f-]{36}\.webp$/i.test(file.name) || typeof file.contentBase64 !== 'string') { - throw new Error('The bootstrap bundle contains an invalid avatar file.'); - } + if ( + typeof file.name !== 'string' || + !/^[0-9a-f-]{36}\.webp$/i.test(file.name) || + typeof file.contentBase64 !== 'string' + ) { + throw new Error('The bootstrap bundle contains an invalid avatar file.'); + } } for (const file of bundle.files.gameIcons) { - if (typeof file.name !== 'string' || !/^[a-z_]+-[0-9a-f-]{36}\.webp$/i.test(file.name) || typeof file.contentBase64 !== 'string') { - throw new Error('The bootstrap bundle contains an invalid game-data icon file.'); - } + if ( + typeof file.name !== 'string' || + !/^[a-z_]+-[0-9a-f-]{36}\.webp$/i.test(file.name) || + typeof file.contentBase64 !== 'string' + ) { + throw new Error( + 'The bootstrap bundle contains an invalid game-data icon file.', + ); + } } -const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build-staging.db'); +const databasePath = resolve( + process.env.TDR_DATABASE_PATH || './data/shd-build-staging.db', +); const safetyBackup = spawnSync(process.execPath, ['scripts/db-backup.mjs'], { - cwd: process.cwd(), - stdio: 'inherit', - env: process.env + cwd: process.cwd(), + stdio: 'inherit', + env: process.env, }); if (safetyBackup.error) throw safetyBackup.error; -if (safetyBackup.status !== 0) throw new Error('Staging safety backup failed; the target was not changed.'); +if (safetyBackup.status !== 0) + throw new Error('Staging safety backup failed; the target was not changed.'); const database = await connect(databasePath); -for (const tableName of ['users', 'builds', 'audit_log', 'data_change_proposals']) { - const row = await (await database.prepare(`SELECT COUNT(*) AS count FROM "${tableName}"`)).get(); - if (Number(row?.count ?? 0) > 0) { - throw new Error(`Staging bootstrap refused: target table "${tableName}" is not empty.`); - } +for (const tableName of [ + 'users', + 'builds', + 'audit_log', + 'data_change_proposals', +]) { + const row = await ( + await database.prepare(`SELECT COUNT(*) AS count FROM "${tableName}"`) + ).get(); + if (Number(row?.count ?? 0) > 0) { + throw new Error( + `Staging bootstrap refused: target table "${tableName}" is not empty.`, + ); + } } -const targetTables = await (await database.prepare(` +const targetTables = await ( + await database.prepare(` SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY rowid -`)).all(); +`) +).all(); const targetNames = new Set(targetTables.map((row) => String(row.name))); -const sourceNames = new Set(bundle.database.tables.map((table) => String(table.name))); +const sourceNames = new Set( + bundle.database.tables.map((table) => String(table.name)), +); for (const row of targetTables) { - if (!sourceNames.has(String(row.name))) { - throw new Error(`Bootstrap snapshot is missing target-schema table "${String(row.name)}".`); - } + if (!sourceNames.has(String(row.name))) { + throw new Error( + `Bootstrap snapshot is missing target-schema table "${String(row.name)}".`, + ); + } } const sourceOnlyTables = bundle.database.tables - .map((table) => String(table.name)) - .filter((name) => !targetNames.has(name)); + .map((table) => String(table.name)) + .filter((name) => !targetNames.has(name)); if (sourceOnlyTables.length) { - throw new Error(`Bootstrap snapshot contains tables outside the current target schema: ${sourceOnlyTables.join(', ')}`); + throw new Error( + `Bootstrap snapshot contains tables outside the current target schema: ${sourceOnlyTables.join(', ')}`, + ); } -const quoteIdentifier = (identifier) => `"${String(identifier).replaceAll('"', '""')}"`; +const quoteIdentifier = (identifier) => + `"${String(identifier).replaceAll('"', '""')}"`; let restoredRows = 0; await database.exec('PRAGMA foreign_keys=OFF'); await database.exec('BEGIN'); try { - for (const row of [...targetTables].reverse()) { - await database.exec(`DELETE FROM ${quoteIdentifier(row.name)}`); - } - for (const table of bundle.database.tables) { - for (const row of table.rows) { - const columns = Object.keys(row); - if (!columns.length) continue; - const insert = await database.prepare( - `INSERT INTO ${quoteIdentifier(table.name)} (${columns.map(quoteIdentifier).join(', ')}) - VALUES (${columns.map(() => '?').join(', ')})` - ); - await insert.run(...columns.map((column) => row[column])); - restoredRows++; - } - } - await database.exec('COMMIT'); + for (const row of [...targetTables].reverse()) { + await database.exec(`DELETE FROM ${quoteIdentifier(row.name)}`); + } + for (const table of bundle.database.tables) { + for (const row of table.rows) { + const columns = Object.keys(row); + if (!columns.length) continue; + const insert = await database.prepare( + `INSERT INTO ${quoteIdentifier(table.name)} (${columns.map(quoteIdentifier).join(', ')}) + VALUES (${columns.map(() => '?').join(', ')})`, + ); + await insert.run(...columns.map((column) => row[column])); + restoredRows++; + } + } + await database.exec('COMMIT'); } catch (error) { - await database.exec('ROLLBACK'); - throw error; + await database.exec('ROLLBACK'); + throw error; } finally { - await database.exec('PRAGMA foreign_keys=ON'); + await database.exec('PRAGMA foreign_keys=ON'); } -const avatarDirectory = resolve(process.env.TDR_AVATAR_STORAGE_PATH || './data/avatars'); -const gameIconDirectory = resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons'); +const avatarDirectory = resolve( + process.env.TDR_AVATAR_STORAGE_PATH || './data/avatars', +); +const gameIconDirectory = resolve( + process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons', +); await mkdir(avatarDirectory, { recursive: true }); await mkdir(gameIconDirectory, { recursive: true }); for (const file of bundle.files.avatars) { - await writeFile(join(avatarDirectory, file.name), Buffer.from(file.contentBase64, 'base64'), { mode: 0o600 }); + await writeFile( + join(avatarDirectory, file.name), + Buffer.from(file.contentBase64, 'base64'), + { mode: 0o600 }, + ); } for (const file of bundle.files.gameIcons) { - await writeFile(join(gameIconDirectory, file.name), Buffer.from(file.contentBase64, 'base64'), { mode: 0o600 }); + await writeFile( + join(gameIconDirectory, file.name), + Buffer.from(file.contentBase64, 'base64'), + { mode: 0o600 }, + ); } -console.log(`Bootstrapped staging with ${restoredRows} database rows, ${bundle.files.avatars.length} avatars, and ${bundle.files.gameIcons.length} game-data icons.`); +console.log( + `Bootstrapped staging with ${restoredRows} database rows, ${bundle.files.avatars.length} avatars, and ${bundle.files.gameIcons.length} game-data icons.`, +); console.log(`Source bundle: ${bundlePath}`); diff --git a/scripts/sync-staging-credentials.mjs b/scripts/sync-staging-credentials.mjs index 9dd001d..61638f1 100644 --- a/scripts/sync-staging-credentials.mjs +++ b/scripts/sync-staging-credentials.mjs @@ -6,41 +6,49 @@ import process from 'node:process'; const stagingFile = resolve('.env.staging'); const dotenvxCli = resolve('node_modules/@dotenvx/dotenvx/src/cli/dotenvx.js'); const sharedKeys = [ - 'DISCORD_CLIENT_ID', - 'DISCORD_CLIENT_SECRET', - 'SMTP_HOST', - 'SMTP_PORT', - 'SMTP_SECURE', - 'SMTP_USER', - 'SMTP_PASSWORD', - 'SMTP_FROM' + 'DISCORD_CLIENT_ID', + 'DISCORD_CLIENT_SECRET', + 'SMTP_HOST', + 'SMTP_PORT', + 'SMTP_SECURE', + 'SMTP_USER', + 'SMTP_PASSWORD', + 'SMTP_FROM', ]; for (const key of sharedKeys) { - if (!process.env[key]) throw new Error(`Source environment is missing ${key}.`); + if (!process.env[key]) + throw new Error(`Source environment is missing ${key}.`); } function dotenvx(action) { - const result = spawnSync(process.execPath, [dotenvxCli, action, '-f', stagingFile], { - cwd: process.cwd(), - stdio: 'inherit' - }); - if (result.error) throw result.error; - if (result.status !== 0) throw new Error(`dotenvx ${action} failed.`); + const result = spawnSync( + process.execPath, + [dotenvxCli, action, '-f', stagingFile], + { + cwd: process.cwd(), + stdio: 'inherit', + }, + ); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(`dotenvx ${action} failed.`); } dotenvx('decrypt'); try { - let contents = await readFile(stagingFile, 'utf8'); - for (const key of sharedKeys) { - const replacement = `${key}=${JSON.stringify(process.env[key])}`; - const pattern = new RegExp(`^${key}=.*$`, 'm'); - if (!pattern.test(contents)) throw new Error(`Staging environment is missing ${key}.`); - contents = contents.replace(pattern, replacement); - } - await writeFile(stagingFile, contents, 'utf8'); + let contents = await readFile(stagingFile, 'utf8'); + for (const key of sharedKeys) { + const replacement = `${key}=${JSON.stringify(process.env[key])}`; + const pattern = new RegExp(`^${key}=.*$`, 'm'); + if (!pattern.test(contents)) + throw new Error(`Staging environment is missing ${key}.`); + contents = contents.replace(pattern, replacement); + } + await writeFile(stagingFile, contents, 'utf8'); } finally { - dotenvx('encrypt'); + dotenvx('encrypt'); } -console.log('Copied shared Discord and SMTP credentials into encrypted staging configuration.'); +console.log( + 'Copied shared Discord and SMTP credentials into encrypted staging configuration.', +); diff --git a/scripts/system-notification-dispatch.mjs b/scripts/system-notification-dispatch.mjs index 5577676..613ebb8 100644 --- a/scripts/system-notification-dispatch.mjs +++ b/scripts/system-notification-dispatch.mjs @@ -3,64 +3,102 @@ import nodemailer from 'nodemailer'; import { randomUUID } from 'node:crypto'; import { resolve } from 'node:path'; -const database = await connect(resolve(process.env.TDR_DATABASE_PATH || './data/shd-build.db')); +const database = await connect( + resolve(process.env.TDR_DATABASE_PATH || './data/shd-build.db'), +); const now = new Date().toISOString(); -const due = await (await database.prepare(`SELECT delivery.id AS delivery_id,delivery.user_id,message.* +const due = await ( + await database.prepare(`SELECT delivery.id AS delivery_id,delivery.user_id,message.* FROM automated_system_message_deliveries delivery JOIN automated_system_messages message ON message.id=delivery.message_id WHERE delivery.delivered_at IS NULL AND delivery.scheduled_for<=? AND message.enabled=1 - ORDER BY delivery.scheduled_for LIMIT 250`)).all(now); + ORDER BY delivery.scheduled_for LIMIT 250`) +).all(now); const smtpConfigured = Boolean(process.env.SMTP_HOST && process.env.SMTP_FROM); -const transport = smtpConfigured ? nodemailer.createTransport({ - host: process.env.SMTP_HOST, - port: Number(process.env.SMTP_PORT || 587), - secure: process.env.SMTP_SECURE === 'true', - auth: process.env.SMTP_USER ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD } : undefined, - requireTLS: process.env.SMTP_SECURE !== 'true', - disableFileAccess: true, - disableUrlAccess: true -}) : null; -const origin = (process.env.APP_ORIGIN || 'http://localhost:5173').replace(/\/+$/, ''); -const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (character) => ({ - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''' -})[character]); +const transport = smtpConfigured + ? nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: Number(process.env.SMTP_PORT || 587), + secure: process.env.SMTP_SECURE === 'true', + auth: process.env.SMTP_USER + ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD } + : undefined, + requireTLS: process.env.SMTP_SECURE !== 'true', + disableFileAccess: true, + disableUrlAccess: true, + }) + : null; +const origin = (process.env.APP_ORIGIN || 'http://localhost:5173').replace( + /\/+$/, + '', +); +const escapeHtml = (value) => + String(value).replace( + /[&<>"']/g, + (character) => + ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + })[character], + ); let delivered = 0; for (const message of due) { - const notificationId = randomUUID(); - await (await database.prepare(`INSERT INTO notifications + const notificationId = randomUUID(); + await ( + await database.prepare(`INSERT INTO notifications (id,user_id,category,source,title,body,link,actions_json,mandatory,created_at) - VALUES(?,?,'isac',?,?,?,?,?,1,?)`)).run( - notificationId, message.user_id, message.source, message.title, message.body, - message.link ?? null, message.actions_json ?? null, now - ); - await (await database.prepare(`UPDATE automated_system_message_deliveries - SET delivered_at=?,notification_id=? WHERE id=? AND delivered_at IS NULL`)) - .run(now, notificationId, message.delivery_id); - const preference = await (await database.prepare( - "SELECT email_frequency FROM notification_preferences WHERE user_id=? AND category='isac'" - )).get(message.user_id); - if (transport && preference?.email_frequency === 'immediate') { - const identity = await (await database.prepare('SELECT email FROM user_emails WHERE user_id=?')).get(message.user_id); - if (typeof identity?.email === 'string') { - try { - await transport.sendMail({ - from: process.env.SMTP_FROM, - to: identity.email, - subject: `SHD.build · ${message.title}`, - text: `${message.body}\n\n${message.link ? `${origin}${message.link}` : origin}`, - html: `

${escapeHtml(message.source)}

${escapeHtml(message.title)}

${escapeHtml(message.body)}

${message.link ? `OPEN SHD.BUILD` : ''}
` - }); - await (await database.prepare('UPDATE notifications SET emailed_at=? WHERE id=?')).run(now, notificationId); - } catch (error) { - console.error(`Unable to email automated notification ${notificationId}`, error); - } - } - } - delivered += 1; + VALUES(?,?,'isac',?,?,?,?,?,1,?)`) + ).run( + notificationId, + message.user_id, + message.source, + message.title, + message.body, + message.link ?? null, + message.actions_json ?? null, + now, + ); + await ( + await database.prepare(`UPDATE automated_system_message_deliveries + SET delivered_at=?,notification_id=? WHERE id=? AND delivered_at IS NULL`) + ).run(now, notificationId, message.delivery_id); + const preference = await ( + await database.prepare( + "SELECT email_frequency FROM notification_preferences WHERE user_id=? AND category='isac'", + ) + ).get(message.user_id); + if (transport && preference?.email_frequency === 'immediate') { + const identity = await ( + await database.prepare('SELECT email FROM user_emails WHERE user_id=?') + ).get(message.user_id); + if (typeof identity?.email === 'string') { + try { + await transport.sendMail({ + from: process.env.SMTP_FROM, + to: identity.email, + subject: `SHD.build · ${message.title}`, + text: `${message.body}\n\n${message.link ? `${origin}${message.link}` : origin}`, + html: `

${escapeHtml(message.source)}

${escapeHtml(message.title)}

${escapeHtml(message.body)}

${message.link ? `OPEN SHD.BUILD` : ''}
`, + }); + await ( + await database.prepare( + 'UPDATE notifications SET emailed_at=? WHERE id=?', + ) + ).run(now, notificationId); + } catch (error) { + console.error( + `Unable to email automated notification ${notificationId}`, + error, + ); + } + } + } + delivered += 1; } -console.log(`Delivered ${delivered} automated ISAC system notification${delivered === 1 ? '' : 's'}.`); +console.log( + `Delivered ${delivered} automated ISAC system notification${delivered === 1 ? '' : 's'}.`, +); diff --git a/scripts/trim-static-icons.mjs b/scripts/trim-static-icons.mjs index 0c8b75a..2f34453 100644 --- a/scripts/trim-static-icons.mjs +++ b/scripts/trim-static-icons.mjs @@ -4,37 +4,41 @@ import { join, resolve } from 'node:path'; const iconDirectory = resolve('./static'); const iconFiles = (await readdir(iconDirectory)) - .filter((file) => file.toLocaleLowerCase().endsWith('.png')) - .sort((left, right) => left.localeCompare(right)); + .filter((file) => file.toLocaleLowerCase().endsWith('.png')) + .sort((left, right) => left.localeCompare(right)); for (const file of iconFiles) { - const source = join(iconDirectory, file); - const temporary = join(iconDirectory, `.${file}.trimmed`); - const before = await sharp(source).metadata(); + const source = join(iconDirectory, file); + const temporary = join(iconDirectory, `.${file}.trimmed`); + const before = await sharp(source).metadata(); - try { - const result = await sharp(source) - .trim({ background: { r: 0, g: 0, b: 0, alpha: 0 } }) - .extend({ - top: 2, - right: 2, - bottom: 2, - left: 2, - background: { r: 0, g: 0, b: 0, alpha: 0 } - }) - .png({ compressionLevel: 9, adaptiveFiltering: true }) - .toFile(temporary); + try { + const result = await sharp(source) + .trim({ background: { r: 0, g: 0, b: 0, alpha: 0 } }) + .extend({ + top: 2, + right: 2, + bottom: 2, + left: 2, + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }) + .png({ compressionLevel: 9, adaptiveFiltering: true }) + .toFile(temporary); - if (!result.width || !result.height || result.channels !== 4) { - throw new Error(`${file} did not produce a valid transparent RGBA icon.`); - } + if (!result.width || !result.height || result.channels !== 4) { + throw new Error(`${file} did not produce a valid transparent RGBA icon.`); + } - await rename(temporary, source); - console.log(`${file}: ${before.width}x${before.height} -> ${result.width}x${result.height}`); - } catch (error) { - await unlink(temporary).catch(() => {}); - throw error; - } + await rename(temporary, source); + console.log( + `${file}: ${before.width}x${before.height} -> ${result.width}x${result.height}`, + ); + } catch (error) { + await unlink(temporary).catch(() => {}); + throw error; + } } -console.log(`Trimmed ${iconFiles.length} static PNG icons with a 2px transparent safety margin.`); +console.log( + `Trimmed ${iconFiles.length} static PNG icons with a 2px transparent safety margin.`, +); diff --git a/src/app.css b/src/app.css index e9aafe6..6aeb33f 100644 --- a/src/app.css +++ b/src/app.css @@ -1,2463 +1,18565 @@ - :root{ - --shd-bg:#0a0c0d; - --shd-surface:#111415; - --shd-surface-raised:#141718; - --shd-surface-active:#15191a; - --shd-border:#303536; - --shd-border-soft:#272c2d; - --shd-border-strong:#424849; - --shd-text:#ecebe5; - --shd-text-muted:#858b8d; - --shd-text-dim:#5d6567; - --shd-orange:#f6a821; - --shd-orange-hover:#ffc45d; - --shd-focus-ring:#f6a821; - --shd-selection-bg:#f6a821; - --shd-selection-text:#111415; - --shd-type-telemetry:12px; - --shd-type-supporting:14px; - --shd-type-body:16px; - --shd-success:#75c08c; - --shd-danger:#df8069; - --shd-info:#70b9d6; - --shd-font-sans:"Barlow","Arial Narrow",Arial,Helvetica,sans-serif; - --shd-font-editorial:"Source Serif 4",Georgia,serif; - --shd-font-mono:"Cascadia Mono","SFMono-Regular",Consolas,monospace; - --quality-standard:#69b66f; - --quality-specialized:#4f9ed8; - --quality-superior:#9b68d4; - --quality-high-end:#e4bd3f; - --shd-radius:0px; - --shd-gap-xs:5px; - --shd-gap-sm:8px; - --shd-gap-md:12px; - --shd-gap-lg:20px; - --shd-control-height:40px; - --isac-icon-editor-width:150px; - --shd-header-height:76px; - --shd-display-scale:1; - --shd-transition:transform .12s ease,filter .18s ease,opacity .18s ease,box-shadow .18s ease; - --shd-model-surface:#0d1213; - --shd-model-border:#3b4648; - --shd-builder-canvas:#0e1112; - --shd-builder-selection:#1b170f; - --shd-builder-selection-border:#8d6728; - } - *{box-sizing:border-box} html{scroll-behavior:smooth} body{min-height:calc(100vh / var(--shd-display-scale));display:flex;flex-direction:column;margin:0;background:var(--shd-bg);color:var(--shd-text);font-family:var(--shd-font-sans);font-optical-sizing:auto;font-synthesis:none} button,input{font:inherit} a,a:hover,a:focus,a:focus-visible,a:active,a:visited{text-decoration:none} - ::selection{background:var(--shd-selection-bg);color:var(--shd-selection-text)}input,textarea{caret-color:var(--shd-orange)}input[type=checkbox],input[type=radio],input[type=range]{accent-color:var(--shd-orange)}:where(input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=file]):not([type=hidden]),select,textarea):focus{border-color:var(--shd-focus-ring);outline:none}:where(input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=file]):not([type=hidden]),select,textarea):focus-visible{outline:1px solid var(--shd-focus-ring);outline-offset:1px}:where(input,textarea):-webkit-autofill,:where(input,textarea):-webkit-autofill:hover,:where(input,textarea):-webkit-autofill:focus{-webkit-text-fill-color:var(--shd-text);caret-color:var(--shd-orange);box-shadow:inset 0 0 0 1000px #15130e;transition:background-color 9999s ease-out} - .sr-only{position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important} - .site-footer{flex:0 0 auto;margin-top:auto} - .contact-page.contact-page{margin:0 auto} - .discord-contact.discord-contact{grid-template-columns:30px minmax(0,1fr) auto;align-items:center;align-content:normal;gap:11px}.discord-contact>img{width:26px;height:20px;object-fit:contain}.discord-contact>span{display:grid;gap:5px}.discord-contact>span small{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1.2px}.discord-contact>span strong{font-size:var(--shd-type-telemetry);letter-spacing:.8px}.discord-contact.discord-contact>b{grid-column:auto;grid-row:auto;align-self:center} - .footer-links .footer-discord{min-height:38px;display:flex;align-items:center;gap:9px;margin-bottom:2px;padding:7px 9px;border:1px solid #5865f2;background:#171a32;color:#fff}.footer-discord img{width:20px;height:16px;object-fit:contain}.footer-discord span{display:grid;gap:3px;font-size:var(--shd-type-telemetry);line-height:1}.footer-discord small{color:#aeb6ff;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.footer-links .footer-discord:hover,.footer-links .footer-discord:focus-visible{border-color:#929bff;background:#202446;color:#fff}.footer-links .footer-discord:active{transform:translateY(1px);filter:brightness(.88)} - .footer-nav{width:max-content;max-width:520px;height:auto;display:flex;flex-wrap:wrap;align-items:center;gap:0;margin:0}.footer-nav a{display:inline-flex;align-items:center;min-height:24px;padding:0 9px;border-left:1px solid var(--shd-border);color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.footer-nav a:first-child{padding-left:0;border-left:0}.footer-nav a:hover,.footer-nav a:focus-visible{color:var(--shd-orange-hover)}@media(max-width:800px){.footer-nav{width:100%;max-width:none}.footer-nav a{min-height:30px}}@media(max-width:500px){.footer-nav{display:grid;grid-template-columns:1fr 1fr}.footer-nav a{padding:0 8px;border-top:1px solid var(--shd-border);border-left:0}.footer-nav a:nth-child(even){border-left:1px solid var(--shd-border)}.footer-nav a:nth-child(-n+2){border-top:0}} - @media(min-width:2200px){:root{--shd-display-scale:1.15}html{zoom:var(--shd-display-scale)}} - @media(min-width:2800px){:root{--shd-display-scale:1.3}} - @media(min-width:3400px){:root{--shd-display-scale:1.5}} - body:before{content:"";position:fixed;inset:0;pointer-events:none;opacity:.08;background-image:linear-gradient(rgba(255,255,255,.08) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.08) 1px,transparent 1px);background-size:48px 48px} - header{height:76px;border-bottom:1px solid #292d2f;display:flex;align-items:center;padding:0 max(24px,5vw);position:relative;z-index:5;background:rgba(10,12,13,.93)} - .brand{display:flex;align-items:center;color:#fff;text-decoration:none;font-weight:900}.accent{color:#f6a821} - nav{margin:auto;display:flex;height:100%}nav button{border:0;background:none;color:#868b8d;padding:0 22px;font-size:12px;font-weight:800;letter-spacing:1.5px;cursor:pointer;border-bottom:2px solid transparent}nav button.active,nav button:hover{color:#fff;border-color:#f6a821} - .sign-in{background:none;border:1px solid #45494a;color:#fff;padding:12px 18px;font-size:var(--shd-type-telemetry);font-weight:800;letter-spacing:1.4px;cursor:pointer}.sign-in span{color:#f6a821;margin-left:12px} - main{position:relative;z-index:1}.hero{min-height:660px;display:grid;grid-template-columns:1fr 1fr;max-width:1400px;margin:auto;padding:76px 5vw 40px;gap:40px;overflow:hidden}.hero-copy{padding-top:28px}.eyebrow{color:#9da1a2;font-size:var(--shd-type-telemetry);letter-spacing:2.4px;font-weight:800}.eyebrow span{display:inline-block;width:28px;height:2px;background:#f6a821;vertical-align:middle;margin-right:10px} - h1,h2,h3,p{margin-top:0}.hero h1{font-size:clamp(58px,7vw,105px);line-height:.85;letter-spacing:-5px;margin:28px 0}.hero h1 em{font-style:normal;color:#f6a821}.lede{color:#9da1a2;font-family:var(--shd-font-editorial);font-size:18px;line-height:1.65;max-width:620px} - .hero-actions{display:flex;gap:14px;margin:36px 0}.primary,.secondary{border:0;padding:16px 22px;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1.6px;cursor:pointer}.primary{background:#f6a821;color:#111}.primary:hover{background:#ffc45d}.primary span{margin-left:16px}.secondary{background:transparent;color:#ddd;border:1px solid #3a3e40} - .metrics{border-top:1px solid #292d2f;padding-top:28px;display:flex;gap:60px}.metrics div{display:flex;flex-direction:column}.metrics strong{font-size:25px}.metrics span{color:#666d70;font-size:var(--shd-type-telemetry);letter-spacing:1.7px;margin-top:6px} - .loadout-preview{min-height:520px;position:relative;border:1px solid #343738;background:radial-gradient(circle at 50% 46%,#30312c 0,#151819 33%,#0d0f10 68%);clip-path:polygon(5% 0,100% 0,100% 92%,95% 100%,0 100%,0 8%)}.scan-lines{position:absolute;inset:0;opacity:.15;background:repeating-linear-gradient(0deg,transparent,transparent 5px,#fff 6px)}.preview-top{padding:22px;display:flex;justify-content:space-between;font-size:var(--shd-type-telemetry);letter-spacing:2px;color:#8d9294}.preview-top b{color:#f6a821}.agent{height:385px;position:relative;display:grid;place-items:center}.agent-shape{width:145px;height:285px;background:linear-gradient(90deg,#1d2121,#737269 46%,#292c2b 53%,#101212);clip-path:polygon(33% 0,67% 0,78% 14%,72% 32%,89% 58%,78% 100%,22% 100%,12% 58%,28% 32%,22% 14%);filter:drop-shadow(0 10px 18px #000);display:grid;place-items:center}.agent-shape span{color:#f6a821;font-weight:900;opacity:.5;transform:rotate(-90deg);letter-spacing:6px}.orbit{position:absolute;border:1px solid #555;border-radius:50%}.orbit-one{width:330px;height:330px}.orbit-two{width:250px;height:250px;border-color:#f6a82144} - .gear-node{position:absolute;width:74px;background:#141718;border:1px solid #555;border-left:2px solid #f6a821;padding:8px;display:flex;flex-direction:column}.gear-node small{color:#777;font-size:var(--shd-type-telemetry);letter-spacing:1px}.gear-node b{font-size:12px;margin-top:3px}.n-0{left:8%;top:12%}.n-1{right:5%;top:24%}.n-2{left:4%;bottom:22%}.n-3{right:8%;bottom:12%}.preview-name{position:absolute;bottom:22px;left:25px;display:flex;flex-direction:column}.preview-name small{color:#f6a821;font-size:var(--shd-type-telemetry)}.preview-name strong{font-size:18px;margin:4px 0}.preview-name span{font-size:var(--shd-type-telemetry);color:#777} - .featured{background:#101314;padding:70px max(24px,5vw) 90px}.section-heading{max-width:1240px;margin:0 auto 28px;display:flex;align-items:end;justify-content:space-between}.section-heading h2{font-size:34px;margin:8px 0}.section-heading button{color:#aaa;background:none;border:0;font-size:var(--shd-type-telemetry);letter-spacing:1px}.build-grid{max-width:1240px;margin:auto;display:grid;grid-template-columns:repeat(3,1fr);gap:20px}.build-grid article{background:#16191a;border:1px solid #2d3132;border-top:2px solid var(--build-color)}.card-top{display:flex;justify-content:space-between;padding:13px 16px;font-size:var(--shd-type-telemetry);letter-spacing:1px}.card-top span:first-child{color:var(--build-color)}.weapon-art{height:135px;background:linear-gradient(135deg,#1e2324,#111);display:grid;place-items:center;position:relative;overflow:hidden}.weapon-art div{width:65%;height:14px;background:var(--build-color);clip-path:polygon(0 20%,65% 20%,75% 0,100% 25%,94% 65%,66% 60%,58% 100%,47% 100%,49% 62%,0 72%);opacity:.65}.weapon-art span{position:absolute;font-size:var(--shd-type-telemetry);color:#ffffff33;letter-spacing:4px}.card-body{padding:20px}.card-body>p{font-size:var(--shd-type-telemetry);color:var(--build-color);letter-spacing:1.5px}.card-body h3{text-transform:uppercase;font-size:19px}.tags{display:flex;gap:7px}.tags span{font-size:var(--shd-type-telemetry);border:1px solid #3b4041;padding:5px 7px;color:#aeb1b2}.author{display:flex;align-items:center;gap:8px;border-top:1px solid #2d3132;margin-top:18px;padding-top:15px;font-size:var(--shd-type-telemetry);color:#777}.author>span:last-child{margin-left:auto}.avatar{display:grid;place-items:center;width:25px;height:25px;border-radius:50%;background:#2f3435;color:#f6a821} - .app-page{max-width:1320px;margin:auto;padding:60px 5vw;min-height:calc(100vh - 76px)}.page-title{display:flex;justify-content:space-between;align-items:end;margin-bottom:35px}.page-title h1{font-size:50px;margin:6px 0}.page-title input{background:transparent;border:0;border-bottom:1px solid #333;color:#fff;font-size:42px;font-weight:800;max-width:600px}.actions{display:flex;align-items:center;gap:14px}.actions>span{font-size:var(--shd-type-telemetry);color:#6a6;opacity:0}.actions>span.visible{opacity:1}.builder-layout{display:grid;grid-template-columns:1fr 340px;gap:24px}.slot-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}.slot-card{text-align:left;min-height:160px;padding:20px;background:#141718;border:1px solid #303536;color:#eee;cursor:pointer;display:grid;grid-template-columns:1fr auto}.slot-card:hover{border-color:#f6a821}.slot-card .slot-label{font-size:var(--shd-type-telemetry);text-transform:uppercase;letter-spacing:1.5px;color:#777}.slot-icon{font-size:30px;color:#f6a821}.slot-card strong,.slot-card small,.slot-card b{grid-column:1/3}.slot-card strong{font-size:20px}.slot-card small{color:#888}.slot-card b{color:#f6a821}.summary,.data-panel{background:#141718;border:1px solid #303536;padding:25px}.summary h2{font-size:25px}.stat{display:grid;grid-template-columns:1fr auto;gap:8px;margin:20px 0}.stat span{display:flex;align-items:center;gap:7px;color:#999;font-size:var(--shd-type-telemetry)}.stat span .game-icon{width:21px;height:21px}.stat i{height:3px;background:#f6a821;grid-column:1/3}.summary hr{border:0;border-top:1px solid #333;margin:28px 0}.mini{display:flex;justify-content:space-between;font-size:var(--shd-type-telemetry);margin:14px 0}.mini span{color:#888}.notice{font-size:var(--shd-type-telemetry);line-height:1.5;color:#747a7c;border-left:2px solid #f6a821;padding-left:12px;margin-top:30px} - .admin-grid{display:grid;grid-template-columns:260px 1fr;gap:20px}.admin-nav{display:flex;flex-direction:column}.admin-nav button{display:flex;justify-content:space-between;background:#111415;color:#9ca0a1;border:0;border-bottom:1px solid #292d2f;padding:18px;text-align:left}.admin-nav button:first-child{color:#f6a821;border-left:2px solid #f6a821}.table-head{display:flex;justify-content:space-between}.table-head p{font-size:var(--shd-type-telemetry);color:#777}.table-head input{height:38px;background:#0e1011;color:white;border:1px solid #333;padding:0 12px}.data-row{display:grid;grid-template-columns:1.4fr 1.2fr .8fr .6fr .3fr;gap:15px;align-items:center;border-top:1px solid #2b3031;padding:18px 4px;font-size:var(--shd-type-telemetry)}.data-row.labels{font-size:var(--shd-type-telemetry);color:#6f7577}.data-row button{background:none;border:0;color:#f6a821;font-size:var(--shd-type-telemetry)}.status{color:#73bd8a;font-size:var(--shd-type-telemetry)} - .auth-page,.empty-page{min-height:calc(100vh - 76px);display:grid;place-items:center;padding:40px}.auth-page section{width:min(430px,100%);background:#141718;border:1px solid #343839;border-top:2px solid #f6a821;padding:38px}.auth-page h1,.empty-page h1{font-size:46px;margin-bottom:12px}.auth-page>section>p:nth-of-type(2){color:#929798;line-height:1.5}.discord{width:100%;padding:15px;background:#5865f2;color:#fff;border:0;font-size:var(--shd-type-telemetry);font-weight:bold;letter-spacing:1px}.divider{display:flex;align-items:center;gap:12px;margin:30px 0 20px;text-align:center}.divider:before,.divider:after{content:"";height:1px;flex:1;background:#333}.divider span{flex:0 0 auto;font-size:var(--shd-type-telemetry);color:#666}.auth-page label{display:block;font-size:var(--shd-type-telemetry);letter-spacing:1px;color:#999;margin:15px 0}.auth-page input{width:100%;display:block;margin-top:7px;background:#0e1011;border:1px solid #35393a;color:#fff;padding:13px}.full{width:100%;margin:8px 0 20px}.auth-page small{display:block;color:#676d6e;line-height:1.5}.empty-page{text-align:center}.empty-page p:not(.eyebrow){color:#888}.toast{position:fixed;z-index:20;right:25px;bottom:25px;background:#f6a821;color:#111;padding:14px 20px;font-size:var(--shd-type-telemetry);font-weight:bold} - @media(max-width:900px){header nav{display:none}.sign-in{margin-left:auto}.hero{grid-template-columns:1fr;padding-top:35px}.hero h1{font-size:62px}.loadout-preview{min-height:500px}.build-grid{grid-template-columns:1fr}.builder-layout,.admin-grid{grid-template-columns:1fr}.slot-grid{grid-template-columns:1fr}.summary{order:-1}.page-title{align-items:start;gap:25px}.page-title input{font-size:30px;width:100%}}@media(max-width:560px){.hero{padding-left:20px;padding-right:20px}.hero h1{font-size:48px;letter-spacing:-3px}.metrics{gap:25px}.hero-actions{flex-direction:column}.loadout-preview{display:none}.hero{min-height:auto;padding-bottom:60px}.page-title{flex-direction:column}.admin-grid{display:block}.data-panel{overflow:auto}.data-row{min-width:650px}.brand{font-size:13px}} - +:root { + --shd-bg: #0a0c0d; + --shd-surface: #111415; + --shd-surface-raised: #141718; + --shd-surface-active: #15191a; + --shd-border: #303536; + --shd-border-soft: #272c2d; + --shd-border-strong: #424849; + --shd-text: #ecebe5; + --shd-text-muted: #858b8d; + --shd-text-dim: #5d6567; + --shd-orange: #f6a821; + --shd-orange-hover: #ffc45d; + --shd-focus-ring: #f6a821; + --shd-selection-bg: #f6a821; + --shd-selection-text: #111415; + --shd-type-telemetry: 12px; + --shd-type-supporting: 14px; + --shd-type-body: 16px; + --shd-success: #75c08c; + --shd-danger: #df8069; + --shd-info: #70b9d6; + --shd-armor-surface: #13211f; + --shd-armor-border: #71aaa5; + --shd-armor-label: #8eb9b4; + --shd-armor-text: #a5d1ca; + --shd-font-sans: 'Barlow', 'Arial Narrow', Arial, Helvetica, sans-serif; + --shd-font-editorial: 'Source Serif 4', Georgia, serif; + --shd-font-mono: 'Cascadia Mono', 'SFMono-Regular', Consolas, monospace; + --quality-standard: #69b66f; + --quality-specialized: #4f9ed8; + --quality-superior: #9b68d4; + --quality-high-end: #e4bd3f; + --shd-radius: 0px; + --shd-gap-xs: 5px; + --shd-gap-sm: 8px; + --shd-gap-md: 12px; + --shd-gap-lg: 20px; + --shd-control-height: 40px; + --isac-icon-editor-width: 150px; + --shd-header-height: 76px; + --shd-display-scale: 1; + --shd-transition: + transform 0.12s ease, filter 0.18s ease, opacity 0.18s ease, + box-shadow 0.18s ease; + --shd-model-surface: #0d1213; + --shd-model-border: #3b4648; + --shd-builder-canvas: #0e1112; + --shd-builder-selection: #1b170f; + --shd-builder-selection-border: #8d6728; +} +* { + box-sizing: border-box; +} +html { + scroll-behavior: smooth; +} +body { + min-height: calc(100vh / var(--shd-display-scale)); + display: flex; + flex-direction: column; + margin: 0; + background: var(--shd-bg); + color: var(--shd-text); + font-family: var(--shd-font-sans); + font-optical-sizing: auto; + font-synthesis: none; +} +button, +input { + font: inherit; +} +a, +a:hover, +a:focus, +a:focus-visible, +a:active, +a:visited { + text-decoration: none; +} +::selection { + background: var(--shd-selection-bg); + color: var(--shd-selection-text); +} +input, +textarea { + caret-color: var(--shd-orange); +} +input[type='checkbox'], +input[type='radio'], +input[type='range'] { + accent-color: var(--shd-orange); +} +:where( + input:not([type='checkbox']):not([type='radio']):not([type='range']):not( + [type='file'] + ):not([type='hidden']), + select, + textarea +):focus { + border-color: var(--shd-focus-ring); + outline: none; +} +:where( + input:not([type='checkbox']):not([type='radio']):not([type='range']):not( + [type='file'] + ):not([type='hidden']), + select, + textarea +):focus-visible { + outline: 1px solid var(--shd-focus-ring); + outline-offset: 1px; +} +:where(input, textarea):-webkit-autofill, +:where(input, textarea):-webkit-autofill:hover, +:where(input, textarea):-webkit-autofill:focus { + -webkit-text-fill-color: var(--shd-text); + caret-color: var(--shd-orange); + box-shadow: inset 0 0 0 1000px #15130e; + transition: background-color 9999s ease-out; +} +.sr-only { + position: absolute !important; + width: 1px !important; + height: 1px !important; + padding: 0 !important; + margin: -1px !important; + overflow: hidden !important; + clip: rect(0, 0, 0, 0) !important; + white-space: nowrap !important; + border: 0 !important; +} +.site-footer { + flex: 0 0 auto; + margin-top: auto; +} +.contact-page.contact-page { + margin: 0 auto; +} +.discord-contact.discord-contact { + grid-template-columns: 30px minmax(0, 1fr) auto; + align-items: center; + align-content: normal; + gap: 11px; +} +.discord-contact > img { + width: 26px; + height: 20px; + object-fit: contain; +} +.discord-contact > span { + display: grid; + gap: 5px; +} +.discord-contact > span small { + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 1.2px; +} +.discord-contact > span strong { + font-size: var(--shd-type-telemetry); + letter-spacing: 0.8px; +} +.discord-contact.discord-contact > b { + grid-column: auto; + grid-row: auto; + align-self: center; +} +.footer-links .footer-discord { + min-height: 38px; + display: flex; + align-items: center; + gap: 9px; + margin-bottom: 2px; + padding: 7px 9px; + border: 1px solid #5865f2; + background: #171a32; + color: #fff; +} +.footer-discord img { + width: 20px; + height: 16px; + object-fit: contain; +} +.footer-discord span { + display: grid; + gap: 3px; + font-size: var(--shd-type-telemetry); + line-height: 1; +} +.footer-discord small { + color: #aeb6ff; + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.8px; +} +.footer-links .footer-discord:hover, +.footer-links .footer-discord:focus-visible { + border-color: #929bff; + background: #202446; + color: #fff; +} +.footer-links .footer-discord:active { + transform: translateY(1px); + filter: brightness(0.88); +} +.footer-nav { + width: max-content; + max-width: 520px; + height: auto; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0; + margin: 0; +} +.footer-nav a { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 0 9px; + border-left: 1px solid var(--shd-border); + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.8px; +} +.footer-nav a:first-child { + padding-left: 0; + border-left: 0; +} +.footer-nav a:hover, +.footer-nav a:focus-visible { + color: var(--shd-orange-hover); +} +@media (max-width: 800px) { + .footer-nav { + width: 100%; + max-width: none; + } + .footer-nav a { + min-height: 30px; + } +} +@media (max-width: 500px) { + .footer-nav { + display: grid; + grid-template-columns: 1fr 1fr; + } + .footer-nav a { + padding: 0 8px; + border-top: 1px solid var(--shd-border); + border-left: 0; + } + .footer-nav a:nth-child(even) { + border-left: 1px solid var(--shd-border); + } + .footer-nav a:nth-child(-n + 2) { + border-top: 0; + } +} +@media (min-width: 2200px) { + :root { + --shd-display-scale: 1.15; + } + html { + zoom: var(--shd-display-scale); + } +} +@media (min-width: 2800px) { + :root { + --shd-display-scale: 1.3; + } +} +@media (min-width: 3400px) { + :root { + --shd-display-scale: 1.5; + } +} +body:before { + content: ''; + position: fixed; + inset: 0; + pointer-events: none; + opacity: 0.08; + background-image: + linear-gradient(rgba(255, 255, 255, 0.08) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.08) 1px, transparent 1px); + background-size: 48px 48px; +} +header { + height: 76px; + border-bottom: 1px solid #292d2f; + display: flex; + align-items: center; + padding: 0 max(24px, 5vw); + position: relative; + z-index: 5; + background: rgba(10, 12, 13, 0.93); +} +.brand { + display: flex; + align-items: center; + color: #fff; + text-decoration: none; + font-weight: 900; +} +.accent { + color: #f6a821; +} +nav { + margin: auto; + display: flex; + height: 100%; +} +nav button { + border: 0; + background: none; + color: #868b8d; + padding: 0 22px; + font-size: 12px; + font-weight: 800; + letter-spacing: 1.5px; + cursor: pointer; + border-bottom: 2px solid transparent; +} +nav button.active, +nav button:hover { + color: #fff; + border-color: #f6a821; +} +.sign-in { + background: none; + border: 1px solid #45494a; + color: #fff; + padding: 12px 18px; + font-size: var(--shd-type-telemetry); + font-weight: 800; + letter-spacing: 1.4px; + cursor: pointer; +} +.sign-in span { + color: #f6a821; + margin-left: 12px; +} +main { + position: relative; + z-index: 1; +} +.hero { + min-height: 660px; + display: grid; + grid-template-columns: 1fr 1fr; + max-width: 1400px; + margin: auto; + padding: 76px 5vw 40px; + gap: 40px; + overflow: hidden; +} +.hero-copy { + padding-top: 28px; +} +.eyebrow { + color: #9da1a2; + font-size: var(--shd-type-telemetry); + letter-spacing: 2.4px; + font-weight: 800; +} +.eyebrow span { + display: inline-block; + width: 28px; + height: 2px; + background: #f6a821; + vertical-align: middle; + margin-right: 10px; +} +h1, +h2, +h3, +p { + margin-top: 0; +} +.hero h1 { + font-size: clamp(58px, 7vw, 105px); + line-height: 0.85; + letter-spacing: -5px; + margin: 28px 0; +} +.hero h1 em { + font-style: normal; + color: #f6a821; +} +.lede { + color: #9da1a2; + font-family: var(--shd-font-editorial); + font-size: 18px; + line-height: 1.65; + max-width: 620px; +} +.hero-actions { + display: flex; + gap: 14px; + margin: 36px 0; +} +.primary, +.secondary { + border: 0; + padding: 16px 22px; + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 1.6px; + cursor: pointer; +} +.primary { + background: #f6a821; + color: #111; +} +.primary:hover { + background: #ffc45d; +} +.primary span { + margin-left: 16px; +} +.secondary { + background: transparent; + color: #ddd; + border: 1px solid #3a3e40; +} +.metrics { + border-top: 1px solid #292d2f; + padding-top: 28px; + display: flex; + gap: 60px; +} +.metrics div { + display: flex; + flex-direction: column; +} +.metrics strong { + font-size: 25px; +} +.metrics span { + color: #666d70; + font-size: var(--shd-type-telemetry); + letter-spacing: 1.7px; + margin-top: 6px; +} +.loadout-preview { + min-height: 520px; + position: relative; + border: 1px solid #343738; + background: radial-gradient( + circle at 50% 46%, + #30312c 0, + #151819 33%, + #0d0f10 68% + ); + clip-path: polygon(5% 0, 100% 0, 100% 92%, 95% 100%, 0 100%, 0 8%); +} +.scan-lines { + position: absolute; + inset: 0; + opacity: 0.15; + background: repeating-linear-gradient( + 0deg, + transparent, + transparent 5px, + #fff 6px + ); +} +.preview-top { + padding: 22px; + display: flex; + justify-content: space-between; + font-size: var(--shd-type-telemetry); + letter-spacing: 2px; + color: #8d9294; +} +.preview-top b { + color: #f6a821; +} +.agent { + height: 385px; + position: relative; + display: grid; + place-items: center; +} +.agent-shape { + width: 145px; + height: 285px; + background: linear-gradient( + 90deg, + #1d2121, + #737269 46%, + #292c2b 53%, + #101212 + ); + clip-path: polygon( + 33% 0, + 67% 0, + 78% 14%, + 72% 32%, + 89% 58%, + 78% 100%, + 22% 100%, + 12% 58%, + 28% 32%, + 22% 14% + ); + filter: drop-shadow(0 10px 18px #000); + display: grid; + place-items: center; +} +.agent-shape span { + color: #f6a821; + font-weight: 900; + opacity: 0.5; + transform: rotate(-90deg); + letter-spacing: 6px; +} +.orbit { + position: absolute; + border: 1px solid #555; + border-radius: 50%; +} +.orbit-one { + width: 330px; + height: 330px; +} +.orbit-two { + width: 250px; + height: 250px; + border-color: #f6a82144; +} +.gear-node { + position: absolute; + width: 74px; + background: #141718; + border: 1px solid #555; + border-left: 2px solid #f6a821; + padding: 8px; + display: flex; + flex-direction: column; +} +.gear-node small { + color: #777; + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; +} +.gear-node b { + font-size: 12px; + margin-top: 3px; +} +.n-0 { + left: 8%; + top: 12%; +} +.n-1 { + right: 5%; + top: 24%; +} +.n-2 { + left: 4%; + bottom: 22%; +} +.n-3 { + right: 8%; + bottom: 12%; +} +.preview-name { + position: absolute; + bottom: 22px; + left: 25px; + display: flex; + flex-direction: column; +} +.preview-name small { + color: #f6a821; + font-size: var(--shd-type-telemetry); +} +.preview-name strong { + font-size: 18px; + margin: 4px 0; +} +.preview-name span { + font-size: var(--shd-type-telemetry); + color: #777; +} +.featured { + background: #101314; + padding: 70px max(24px, 5vw) 90px; +} +.section-heading { + max-width: 1240px; + margin: 0 auto 28px; + display: flex; + align-items: end; + justify-content: space-between; +} +.section-heading h2 { + font-size: 34px; + margin: 8px 0; +} +.section-heading button { + color: #aaa; + background: none; + border: 0; + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; +} +.build-grid { + max-width: 1240px; + margin: auto; + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; +} +.build-grid article { + background: #16191a; + border: 1px solid #2d3132; + border-top: 2px solid var(--build-color); +} +.card-top { + display: flex; + justify-content: space-between; + padding: 13px 16px; + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; +} +.card-top span:first-child { + color: var(--build-color); +} +.weapon-art { + height: 135px; + background: linear-gradient(135deg, #1e2324, #111); + display: grid; + place-items: center; + position: relative; + overflow: hidden; +} +.weapon-art div { + width: 65%; + height: 14px; + background: var(--build-color); + clip-path: polygon( + 0 20%, + 65% 20%, + 75% 0, + 100% 25%, + 94% 65%, + 66% 60%, + 58% 100%, + 47% 100%, + 49% 62%, + 0 72% + ); + opacity: 0.65; +} +.weapon-art span { + position: absolute; + font-size: var(--shd-type-telemetry); + color: #ffffff33; + letter-spacing: 4px; +} +.card-body { + padding: 20px; +} +.card-body > p { + font-size: var(--shd-type-telemetry); + color: var(--build-color); + letter-spacing: 1.5px; +} +.card-body h3 { + text-transform: uppercase; + font-size: 19px; +} +.tags { + display: flex; + gap: 7px; +} +.tags span { + font-size: var(--shd-type-telemetry); + border: 1px solid #3b4041; + padding: 5px 7px; + color: #aeb1b2; +} +.author { + display: flex; + align-items: center; + gap: 8px; + border-top: 1px solid #2d3132; + margin-top: 18px; + padding-top: 15px; + font-size: var(--shd-type-telemetry); + color: #777; +} +.author > span:last-child { + margin-left: auto; +} +.avatar { + display: grid; + place-items: center; + width: 25px; + height: 25px; + border-radius: 50%; + background: #2f3435; + color: #f6a821; +} +.app-page { + max-width: 1320px; + margin: auto; + padding: 60px 5vw; + min-height: calc(100vh - 76px); +} +.page-title { + display: flex; + justify-content: space-between; + align-items: end; + margin-bottom: 35px; +} +.page-title h1 { + font-size: 50px; + margin: 6px 0; +} +.page-title input { + background: transparent; + border: 0; + border-bottom: 1px solid #333; + color: #fff; + font-size: 42px; + font-weight: 800; + max-width: 600px; +} +.actions { + display: flex; + align-items: center; + gap: 14px; +} +.actions > span { + font-size: var(--shd-type-telemetry); + color: #6a6; + opacity: 0; +} +.actions > span.visible { + opacity: 1; +} +.builder-layout { + display: grid; + grid-template-columns: 1fr 340px; + gap: 24px; +} +.slot-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; +} +.slot-card { + text-align: left; + min-height: 160px; + padding: 20px; + background: #141718; + border: 1px solid #303536; + color: #eee; + cursor: pointer; + display: grid; + grid-template-columns: 1fr auto; +} +.slot-card:hover { + border-color: #f6a821; +} +.slot-card .slot-label { + font-size: var(--shd-type-telemetry); + text-transform: uppercase; + letter-spacing: 1.5px; + color: #777; +} +.slot-icon { + font-size: 30px; + color: #f6a821; +} +.slot-card strong, +.slot-card small, +.slot-card b { + grid-column: 1/3; +} +.slot-card strong { + font-size: 20px; +} +.slot-card small { + color: #888; +} +.slot-card b { + color: #f6a821; +} +.summary, +.data-panel { + background: #141718; + border: 1px solid #303536; + padding: 25px; +} +.summary h2 { + font-size: 25px; +} +.stat { + display: grid; + grid-template-columns: 1fr auto; + gap: 8px; + margin: 20px 0; +} +.stat span { + display: flex; + align-items: center; + gap: 7px; + color: #999; + font-size: var(--shd-type-telemetry); +} +.stat span .game-icon { + width: 21px; + height: 21px; +} +.stat i { + height: 3px; + background: #f6a821; + grid-column: 1/3; +} +.summary hr { + border: 0; + border-top: 1px solid #333; + margin: 28px 0; +} +.mini { + display: flex; + justify-content: space-between; + font-size: var(--shd-type-telemetry); + margin: 14px 0; +} +.mini span { + color: #888; +} +.notice { + font-size: var(--shd-type-telemetry); + line-height: 1.5; + color: #747a7c; + border-left: 2px solid #f6a821; + padding-left: 12px; + margin-top: 30px; +} +.admin-grid { + display: grid; + grid-template-columns: 260px 1fr; + gap: 20px; +} +.admin-nav { + display: flex; + flex-direction: column; +} +.admin-nav button { + display: flex; + justify-content: space-between; + background: #111415; + color: #9ca0a1; + border: 0; + border-bottom: 1px solid #292d2f; + padding: 18px; + text-align: left; +} +.admin-nav button:first-child { + color: #f6a821; + border-left: 2px solid #f6a821; +} +.table-head { + display: flex; + justify-content: space-between; +} +.table-head p { + font-size: var(--shd-type-telemetry); + color: #777; +} +.table-head input { + height: 38px; + background: #0e1011; + color: white; + border: 1px solid #333; + padding: 0 12px; +} +.data-row { + display: grid; + grid-template-columns: 1.4fr 1.2fr 0.8fr 0.6fr 0.3fr; + gap: 15px; + align-items: center; + border-top: 1px solid #2b3031; + padding: 18px 4px; + font-size: var(--shd-type-telemetry); +} +.data-row.labels { + font-size: var(--shd-type-telemetry); + color: #6f7577; +} +.data-row button { + background: none; + border: 0; + color: #f6a821; + font-size: var(--shd-type-telemetry); +} +.status { + color: #73bd8a; + font-size: var(--shd-type-telemetry); +} +.auth-page, +.empty-page { + min-height: calc(100vh - 76px); + display: grid; + place-items: center; + padding: 40px; +} +.auth-page section { + width: min(430px, 100%); + background: #141718; + border: 1px solid #343839; + border-top: 2px solid #f6a821; + padding: 38px; +} +.auth-page h1, +.empty-page h1 { + font-size: 46px; + margin-bottom: 12px; +} +.auth-page > section > p:nth-of-type(2) { + color: #929798; + line-height: 1.5; +} +.discord { + width: 100%; + padding: 15px; + background: #5865f2; + color: #fff; + border: 0; + font-size: var(--shd-type-telemetry); + font-weight: bold; + letter-spacing: 1px; +} +.divider { + display: flex; + align-items: center; + gap: 12px; + margin: 30px 0 20px; + text-align: center; +} +.divider:before, +.divider:after { + content: ''; + height: 1px; + flex: 1; + background: #333; +} +.divider span { + flex: 0 0 auto; + font-size: var(--shd-type-telemetry); + color: #666; +} +.auth-page label { + display: block; + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; + color: #999; + margin: 15px 0; +} +.auth-page input { + width: 100%; + display: block; + margin-top: 7px; + background: #0e1011; + border: 1px solid #35393a; + color: #fff; + padding: 13px; +} +.full { + width: 100%; + margin: 8px 0 20px; +} +.auth-page small { + display: block; + color: #676d6e; + line-height: 1.5; +} +.empty-page { + text-align: center; +} +.empty-page p:not(.eyebrow) { + color: #888; +} +.toast { + position: fixed; + z-index: 20; + right: 25px; + bottom: 25px; + background: #f6a821; + color: #111; + padding: 14px 20px; + font-size: var(--shd-type-telemetry); + font-weight: bold; +} +@media (max-width: 900px) { + header nav { + display: none; + } + .sign-in { + margin-left: auto; + } + .hero { + grid-template-columns: 1fr; + padding-top: 35px; + } + .hero h1 { + font-size: 62px; + } + .loadout-preview { + min-height: 500px; + } + .build-grid { + grid-template-columns: 1fr; + } + .builder-layout, + .admin-grid { + grid-template-columns: 1fr; + } + .slot-grid { + grid-template-columns: 1fr; + } + .summary { + order: -1; + } + .page-title { + align-items: start; + gap: 25px; + } + .page-title input { + font-size: 30px; + width: 100%; + } +} +@media (max-width: 560px) { + .hero { + padding-left: 20px; + padding-right: 20px; + } + .hero h1 { + font-size: 48px; + letter-spacing: -3px; + } + .metrics { + gap: 25px; + } + .hero-actions { + flex-direction: column; + } + .loadout-preview { + display: none; + } + .hero { + min-height: auto; + padding-bottom: 60px; + } + .page-title { + flex-direction: column; + } + .admin-grid { + display: block; + } + .data-panel { + overflow: auto; + } + .data-row { + min-width: 650px; + } + .brand { + font-size: 13px; + } +} + /* Routed application compatibility: preserve the original SHD visual system. */ -.site-header{height:76px}.site-header nav a{display:grid;place-items:center;color:#868b8d;padding:0 22px;font-size:12px;font-weight:800;letter-spacing:1.5px;border-bottom:2px solid transparent}.site-header nav a.active,.site-header nav a:hover{color:#fff;border-color:#f6a821}.site-header .account{margin-left:auto;display:flex;align-items:center;gap:15px}.site-header .account>a{display:flex;flex-direction:column;font-size:var(--shd-type-telemetry);font-weight:800}.site-header .account small{font-size:var(--shd-type-telemetry);color:#6f7577}.site-header .account button{background:none;border:1px solid #45494a;color:#fff;padding:12px 18px;font-size:var(--shd-type-telemetry);font-weight:800;letter-spacing:1.2px} -.page{width:100%;max-width:1640px;margin:auto;padding:52px clamp(18px,3vw,48px);min-height:calc(100vh - 76px)}.page.hero{max-width:1400px;padding:76px 5vw 40px}.page-head{display:flex;justify-content:space-between;align-items:end;margin-bottom:35px}.page-head h1{font-size:50px;margin:6px 0;text-transform:uppercase}.page-head h2{font-size:25px;margin:5px 0}.page-head>div>input{background:transparent;border:0;border-bottom:1px solid #333;color:#fff;font-size:42px;font-weight:800;max-width:600px}.muted{color:#858b8d;line-height:1.55} -.panel,.card,.table{background:#141718;border:1px solid #303536}.panel,.card{padding:24px}.grid{display:grid;gap:20px}.cards{grid-template-columns:repeat(3,minmax(0,1fr))}.card{border-top:2px solid #f6a821}.card small,.label{font-size:var(--shd-type-telemetry);color:#747a7c;letter-spacing:1.4px}.card h3{font-size:19px;text-transform:uppercase;margin:14px 0}.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.field{display:block;font-size:var(--shd-type-telemetry);letter-spacing:1.2px;color:#999;margin:9px 0}.field input,.field select,.field textarea,input,select,textarea{display:block;width:100%;margin-top:6px;background:#0d0f10;border:1px solid #363b3c;color:#fff;padding:11px}.field textarea{resize:vertical}.form-actions{display:flex;justify-content:flex-end;gap:10px;border-top:1px solid #303536;margin-top:14px;padding-top:14px}.error,.success{font-size:var(--shd-type-telemetry);border-left:2px solid;padding-left:10px}.error{color:#ff8e78;border-color:#ff6648}.success{color:#83c998;border-color:#55b673} -.builder{display:grid;grid-template-columns:1fr 340px;gap:24px}.slots{display:grid;grid-template-columns:1fr 1fr;gap:14px}.slot{text-align:left;min-height:160px;background:#141718;border:1px solid #303536;color:#eee;cursor:pointer}.slot:hover{border-color:#f6a821}.stats{height:max-content}.stats div{display:flex;justify-content:space-between;font-size:var(--shd-type-telemetry);padding:14px 0;border-bottom:1px solid #303536} -.admin-layout{display:grid;grid-template-columns:210px minmax(0,1fr);gap:12px;align-items:start}.admin-nav{position:sticky;top:88px;display:flex;flex-direction:column;background:#111415;border:1px solid #2d3233;padding:8px 0}.admin-nav a{color:#9ca0a1;border-bottom:1px solid #292d2f;padding:13px 14px;font-size:var(--shd-type-telemetry)}.admin-nav a.active{color:#f6a821;border-left:2px solid #f6a821}.table{overflow:auto}.table-row{display:grid;grid-template-columns:1.5fr repeat(3,1fr) .35fr;gap:10px;align-items:center;border-top:1px solid #2b3031;padding:11px 14px;font-size:var(--shd-type-telemetry);min-width:700px}.table-row.head{font-size:var(--shd-type-telemetry);color:#6f7577}.table-row button,.card button:not(.primary):not(.secondary){background:none;border:0;border-bottom:1px solid transparent;color:#f6a821;font-size:var(--shd-type-telemetry);cursor:pointer}.table-row button:hover{border-bottom-color:#f6a821}.primary,.secondary,.danger{display:inline-block;text-decoration:none}.auth-page{min-height:calc(100vh - 76px)}hr{border:0;border-top:1px solid #303536;margin:26px 0}button:disabled,fieldset[disabled]{cursor:wait}button:not(:disabled):active,a.primary:active,a.secondary:active{transform:translateY(2px) scale(.985);filter:brightness(.9)} -@media(max-width:900px){.site-header nav{display:none}.cards,.builder,.admin-layout,.form-grid{grid-template-columns:1fr}.slots{grid-template-columns:1fr}.admin-nav{position:static;display:grid;grid-template-columns:1fr 1fr}.page-head{align-items:flex-start;gap:22px}}@media(max-width:560px){.page-head{flex-direction:column}.site-header .account>a{display:none}.cards{grid-template-columns:1fr}} +.site-header { + height: 76px; +} +.site-header nav a { + display: grid; + place-items: center; + color: #868b8d; + padding: 0 22px; + font-size: 12px; + font-weight: 800; + letter-spacing: 1.5px; + border-bottom: 2px solid transparent; +} +.site-header nav a.active, +.site-header nav a:hover { + color: #fff; + border-color: #f6a821; +} +.site-header .account { + margin-left: auto; + display: flex; + align-items: center; + gap: 15px; +} +.site-header .account > a { + display: flex; + flex-direction: column; + font-size: var(--shd-type-telemetry); + font-weight: 800; +} +.site-header .account small { + font-size: var(--shd-type-telemetry); + color: #6f7577; +} +.site-header .account button { + background: none; + border: 1px solid #45494a; + color: #fff; + padding: 12px 18px; + font-size: var(--shd-type-telemetry); + font-weight: 800; + letter-spacing: 1.2px; +} +.page { + width: 100%; + max-width: 1640px; + margin: auto; + padding: 52px clamp(18px, 3vw, 48px); + min-height: calc(100vh - 76px); +} +.page.hero { + max-width: 1400px; + padding: 76px 5vw 40px; +} +.page-head { + display: flex; + justify-content: space-between; + align-items: end; + margin-bottom: 35px; +} +.page-head h1 { + font-size: 50px; + margin: 6px 0; + text-transform: uppercase; +} +.page-head h2 { + font-size: 25px; + margin: 5px 0; +} +.page-head > div > input { + background: transparent; + border: 0; + border-bottom: 1px solid #333; + color: #fff; + font-size: 42px; + font-weight: 800; + max-width: 600px; +} +.muted { + color: #858b8d; + line-height: 1.55; +} +.panel, +.card, +.table { + background: #141718; + border: 1px solid #303536; +} +.panel, +.card { + padding: 24px; +} +.grid { + display: grid; + gap: 20px; +} +.cards { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} +.card { + border-top: 2px solid #f6a821; +} +.card small, +.label { + font-size: var(--shd-type-telemetry); + color: #747a7c; + letter-spacing: 1.4px; +} +.card h3 { + font-size: 19px; + text-transform: uppercase; + margin: 14px 0; +} +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} +.field { + display: block; + font-size: var(--shd-type-telemetry); + letter-spacing: 1.2px; + color: #999; + margin: 9px 0; +} +.field input, +.field select, +.field textarea, +input, +select, +textarea { + display: block; + width: 100%; + margin-top: 6px; + background: #0d0f10; + border: 1px solid #363b3c; + color: #fff; + padding: 11px; +} +.field textarea { + resize: vertical; +} +.form-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + border-top: 1px solid #303536; + margin-top: 14px; + padding-top: 14px; +} +.error, +.success { + font-size: var(--shd-type-telemetry); + border-left: 2px solid; + padding-left: 10px; +} +.error { + color: #ff8e78; + border-color: #ff6648; +} +.success { + color: #83c998; + border-color: #55b673; +} +.builder { + display: grid; + grid-template-columns: 1fr 340px; + gap: 24px; +} +.slots { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; +} +.slot { + text-align: left; + min-height: 160px; + background: #141718; + border: 1px solid #303536; + color: #eee; + cursor: pointer; +} +.slot:hover { + border-color: #f6a821; +} +.stats { + height: max-content; +} +.stats div { + display: flex; + justify-content: space-between; + font-size: var(--shd-type-telemetry); + padding: 14px 0; + border-bottom: 1px solid #303536; +} +.admin-layout { + display: grid; + grid-template-columns: 210px minmax(0, 1fr); + gap: 12px; + align-items: start; +} +.admin-nav { + position: sticky; + top: 88px; + display: flex; + flex-direction: column; + background: #111415; + border: 1px solid #2d3233; + padding: 8px 0; +} +.admin-nav a { + color: #9ca0a1; + border-bottom: 1px solid #292d2f; + padding: 13px 14px; + font-size: var(--shd-type-telemetry); +} +.admin-nav a.active { + color: #f6a821; + border-left: 2px solid #f6a821; +} +.table { + overflow: auto; +} +.table-row { + display: grid; + grid-template-columns: 1.5fr repeat(3, 1fr) 0.35fr; + gap: 10px; + align-items: center; + border-top: 1px solid #2b3031; + padding: 11px 14px; + font-size: var(--shd-type-telemetry); + min-width: 700px; +} +.table-row.head { + font-size: var(--shd-type-telemetry); + color: #6f7577; +} +.table-row button, +.card button:not(.primary):not(.secondary) { + background: none; + border: 0; + border-bottom: 1px solid transparent; + color: #f6a821; + font-size: var(--shd-type-telemetry); + cursor: pointer; +} +.table-row button:hover { + border-bottom-color: #f6a821; +} +.primary, +.secondary, +.danger { + display: inline-block; + text-decoration: none; +} +.auth-page { + min-height: calc(100vh - 76px); +} +hr { + border: 0; + border-top: 1px solid #303536; + margin: 26px 0; +} +button:disabled, +fieldset[disabled] { + cursor: wait; +} +button:not(:disabled):active, +a.primary:active, +a.secondary:active { + transform: translateY(2px) scale(0.985); + filter: brightness(0.9); +} +@media (max-width: 900px) { + .site-header nav { + display: none; + } + .cards, + .builder, + .admin-layout, + .form-grid { + grid-template-columns: 1fr; + } + .slots { + grid-template-columns: 1fr; + } + .admin-nav { + position: static; + display: grid; + grid-template-columns: 1fr 1fr; + } + .page-head { + align-items: flex-start; + gap: 22px; + } +} +@media (max-width: 560px) { + .page-head { + flex-direction: column; + } + .site-header .account > a { + display: none; + } + .cards { + grid-template-columns: 1fr; + } +} /* SHD routed exception console */ -.error-page{min-height:calc(100vh - var(--shd-header-height));display:grid;place-items:center;padding:clamp(20px,5vw,72px);background:radial-gradient(circle at 50% 45%,#282116 0,transparent 38%)}.error-console{width:min(900px,100%);border:1px solid #4a4337;border-top:2px solid var(--shd-orange);background:linear-gradient(135deg,#141718,#0d1011);box-shadow:0 24px 70px #0008}.error-console.server-fault{border-top-color:var(--shd-danger)}.error-console>header{position:static;height:65px;padding:0 18px;border-bottom:1px solid var(--shd-border);background:#101314}.error-console>header>div{display:grid;gap:4px}.error-console>header span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1.4px}.error-console>header strong{color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:1px}.error-console.server-fault>header strong,.error-console.server-fault>header>b{color:var(--shd-danger)}.error-console>header>b{color:var(--shd-orange);font-size:32px;letter-spacing:-1px}.error-signal{height:78px;display:grid;grid-template-columns:1fr auto 1fr;align-items:center;gap:15px;padding:0 clamp(18px,6vw,60px);color:var(--shd-orange)}.server-fault .error-signal{color:var(--shd-danger)}.error-signal i{height:1px;background:linear-gradient(90deg,transparent,currentColor)}.error-signal i:last-child{background:linear-gradient(90deg,currentColor,transparent)}.error-signal span{width:44px;height:44px;display:grid;place-items:center;border:1px solid currentColor;font-size:var(--shd-type-telemetry);font-weight:900;box-shadow:0 0 18px #f6a82122}.error-copy{padding:0 clamp(24px,7vw,78px) 34px;text-align:center}.error-copy .eyebrow{margin-bottom:15px}.error-copy h1{max-width:720px;margin:0 auto 17px;font-size:clamp(38px,6vw,72px);line-height:.9;letter-spacing:-2.5px}.error-copy>p:last-child{max-width:650px;margin:0 auto;color:var(--shd-text-muted);font:15px/1.6 var(--shd-font-editorial)}.error-console dl{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));margin:0;border-top:1px solid var(--shd-border);border-bottom:1px solid var(--shd-border)}.error-console dl>div{min-width:0;display:grid;gap:5px;padding:12px 15px;border-left:1px solid var(--shd-border)}.error-console dl>div:first-child{border-left:0}.error-console dt{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1px}.error-console dd{overflow:hidden;margin:0;color:#c7cbcc;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.5px;text-overflow:ellipsis;white-space:nowrap}.error-console>footer{min-height:68px;display:flex;align-items:center;justify-content:space-between;gap:18px;padding:12px 16px}.error-console>footer>div{display:flex;align-items:center;gap:8px;color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.error-console>footer>div i{width:6px;height:6px;background:var(--shd-success);box-shadow:0 0 7px var(--shd-success)}.error-console.server-fault>footer>div i{background:var(--shd-danger);box-shadow:0 0 7px var(--shd-danger)}.error-console>footer nav{height:auto;margin:0;display:flex;gap:8px}.error-console>footer .primary,.error-console>footer .secondary{min-height:40px;display:inline-grid;place-items:center;padding:0 15px}@media(max-width:700px){.error-page{place-items:start center}.error-console dl{grid-template-columns:1fr 1fr}.error-console dl>div:nth-child(3){border-left:0}.error-console dl>div:nth-child(n+3){border-top:1px solid var(--shd-border)}.error-console>footer{align-items:stretch;flex-direction:column}.error-console>footer nav{width:100%}.error-console>footer nav>*{flex:1}.error-copy h1{letter-spacing:-1.5px}}@media(max-width:430px){.error-console dl{grid-template-columns:1fr}.error-console dl>div{border-left:0;border-top:1px solid var(--shd-border)}.error-console dl>div:first-child{border-top:0}.error-console>footer nav{flex-direction:column}} +.error-page { + min-height: calc(100vh - var(--shd-header-height)); + display: grid; + place-items: center; + padding: clamp(20px, 5vw, 72px); + background: radial-gradient(circle at 50% 45%, #282116 0, transparent 38%); +} +.error-console { + width: min(900px, 100%); + border: 1px solid #4a4337; + border-top: 2px solid var(--shd-orange); + background: linear-gradient(135deg, #141718, #0d1011); + box-shadow: 0 24px 70px #0008; +} +.error-console.server-fault { + border-top-color: var(--shd-danger); +} +.error-console > header { + position: static; + height: 65px; + padding: 0 18px; + border-bottom: 1px solid var(--shd-border); + background: #101314; +} +.error-console > header > div { + display: grid; + gap: 4px; +} +.error-console > header span { + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 1.4px; +} +.error-console > header strong { + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; +} +.error-console.server-fault > header strong, +.error-console.server-fault > header > b { + color: var(--shd-danger); +} +.error-console > header > b { + color: var(--shd-orange); + font-size: 32px; + letter-spacing: -1px; +} +.error-signal { + height: 78px; + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 15px; + padding: 0 clamp(18px, 6vw, 60px); + color: var(--shd-orange); +} +.server-fault .error-signal { + color: var(--shd-danger); +} +.error-signal i { + height: 1px; + background: linear-gradient(90deg, transparent, currentColor); +} +.error-signal i:last-child { + background: linear-gradient(90deg, currentColor, transparent); +} +.error-signal span { + width: 44px; + height: 44px; + display: grid; + place-items: center; + border: 1px solid currentColor; + font-size: var(--shd-type-telemetry); + font-weight: 900; + box-shadow: 0 0 18px #f6a82122; +} +.error-copy { + padding: 0 clamp(24px, 7vw, 78px) 34px; + text-align: center; +} +.error-copy .eyebrow { + margin-bottom: 15px; +} +.error-copy h1 { + max-width: 720px; + margin: 0 auto 17px; + font-size: clamp(38px, 6vw, 72px); + line-height: 0.9; + letter-spacing: -2.5px; +} +.error-copy > p:last-child { + max-width: 650px; + margin: 0 auto; + color: var(--shd-text-muted); + font: 15px/1.6 var(--shd-font-editorial); +} +.error-console dl { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin: 0; + border-top: 1px solid var(--shd-border); + border-bottom: 1px solid var(--shd-border); +} +.error-console dl > div { + min-width: 0; + display: grid; + gap: 5px; + padding: 12px 15px; + border-left: 1px solid var(--shd-border); +} +.error-console dl > div:first-child { + border-left: 0; +} +.error-console dt { + color: var(--shd-text-dim); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 1px; +} +.error-console dd { + overflow: hidden; + margin: 0; + color: #c7cbcc; + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.5px; + text-overflow: ellipsis; + white-space: nowrap; +} +.error-console > footer { + min-height: 68px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + padding: 12px 16px; +} +.error-console > footer > div { + display: flex; + align-items: center; + gap: 8px; + color: var(--shd-text-dim); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.8px; +} +.error-console > footer > div i { + width: 6px; + height: 6px; + background: var(--shd-success); + box-shadow: 0 0 7px var(--shd-success); +} +.error-console.server-fault > footer > div i { + background: var(--shd-danger); + box-shadow: 0 0 7px var(--shd-danger); +} +.error-console > footer nav { + height: auto; + margin: 0; + display: flex; + gap: 8px; +} +.error-console > footer .primary, +.error-console > footer .secondary { + min-height: 40px; + display: inline-grid; + place-items: center; + padding: 0 15px; +} +@media (max-width: 700px) { + .error-page { + place-items: start center; + } + .error-console dl { + grid-template-columns: 1fr 1fr; + } + .error-console dl > div:nth-child(3) { + border-left: 0; + } + .error-console dl > div:nth-child(n + 3) { + border-top: 1px solid var(--shd-border); + } + .error-console > footer { + align-items: stretch; + flex-direction: column; + } + .error-console > footer nav { + width: 100%; + } + .error-console > footer nav > * { + flex: 1; + } + .error-copy h1 { + letter-spacing: -1.5px; + } +} +@media (max-width: 430px) { + .error-console dl { + grid-template-columns: 1fr; + } + .error-console dl > div { + border-left: 0; + border-top: 1px solid var(--shd-border); + } + .error-console dl > div:first-child { + border-top: 0; + } + .error-console > footer nav { + flex-direction: column; + } +} /* SHD admin operations dashboard */ -.admin-page{width:100%;min-height:calc(100vh - 76px);padding:18px clamp(14px,2vw,32px) 40px;background:linear-gradient(135deg,#101314aa 0,transparent 38%)}.admin-command{height:auto;min-height:76px;position:relative;top:auto;display:flex;justify-content:space-between;align-items:center;padding:0 0 16px;background:transparent;border-bottom:1px solid #303536}.command-title h1{margin:4px 0 2px;font-size:27px;letter-spacing:1px}.command-title>p:last-child{margin:0;color:#697073;font-size:var(--shd-type-telemetry);letter-spacing:1.1px}.command-status{display:flex;gap:7px}.command-status>div{position:relative;min-width:105px;height:47px;padding:9px 12px;background:#111415;border:1px solid #303536;display:grid;align-content:center}.command-status span{font-size:var(--shd-type-telemetry);letter-spacing:1.3px;color:#6f7678}.command-status strong{font-size:var(--shd-type-telemetry);letter-spacing:1px;margin-top:3px}.command-status i{position:absolute;right:9px;top:9px;width:6px;height:6px;border-radius:50%;background:#68bd82;box-shadow:0 0 8px #68bd82}.admin-dashboard{display:grid;grid-template-columns:210px minmax(0,1fr);gap:12px;margin-top:12px}.admin-sidebar{position:sticky;top:88px;min-height:520px;height:calc(100vh - 118px);display:flex;flex-direction:column;background:#0f1213;border:1px solid #2c3132}.sidebar-label{height:43px;padding:10px 12px;border-bottom:1px solid #2c3132;display:flex;justify-content:space-between;align-items:center}.sidebar-label span,.sidebar-label b{font-size:var(--shd-type-telemetry);letter-spacing:1.3px}.sidebar-label span{color:#8d9395}.sidebar-label b{color:#505759}.admin-sidebar nav{display:flex;flex-direction:column;margin:0;height:auto}.admin-sidebar nav a{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;min-height:49px;padding:0 12px 0 0;color:#858c8e;border:0;border-bottom:1px solid #272c2d;font-size:var(--shd-type-telemetry)}.admin-sidebar nav a small{height:100%;display:grid;place-items:center;background:#141819;color:#535a5c;font-size:var(--shd-type-telemetry);letter-spacing:.6px}.admin-sidebar nav a span{padding-left:12px}.admin-sidebar nav a b{font-size:17px;color:#f6a821}.admin-sidebar nav a em{font-style:normal;font-size:var(--shd-type-telemetry);color:#555c5e;letter-spacing:1px}.admin-sidebar nav a:hover,.admin-sidebar nav a.active{color:#fff;background:#151819}.admin-sidebar nav a.active{box-shadow:inset 2px 0 #f6a821}.admin-sidebar nav a.active small{background:#2b2111;color:#f6a821}.admin-sidebar nav a.pending{opacity:.5;pointer-events:none}.sidebar-foot{margin-top:auto;padding:14px;border-top:1px solid #2c3132;display:grid}.sidebar-foot span{font-size:var(--shd-type-telemetry);color:#5e6668;letter-spacing:1.3px}.sidebar-foot strong{font-size:14px;color:#f6a821;margin:4px 0}.sidebar-foot small{font-size:var(--shd-type-telemetry);color:#596164}.admin-content{min-width:0}.module-toolbar{min-height:63px;display:flex;align-items:center;justify-content:space-between;padding:0 2px 12px;border-bottom:1px solid #303536}.module-toolbar h2{font-size:19px;letter-spacing:.4px;margin:3px 0}.module-toolbar p{margin:0}.module-toolbar>div>p:last-child{font-size:var(--shd-type-telemetry);color:#71787a}.module-toolbar>div:last-child{display:flex;gap:8px}.module-toolbar .primary,.module-toolbar .secondary{height:40px;display:inline-flex;align-items:center;justify-content:center;padding:0 15px}.module-kpis{display:grid;grid-template-columns:repeat(4,minmax(120px,1fr));gap:8px;margin:10px 0}.module-kpis>div{position:relative;min-height:70px;padding:11px 13px;background:#111415;border:1px solid #2d3233;border-top:2px solid #3a4041;display:grid}.module-kpis>div:first-child{border-top-color:#f6a821}.module-kpis span{font-size:var(--shd-type-telemetry);letter-spacing:1.3px;color:#737a7c}.module-kpis strong{font-size:23px;line-height:1.1;margin:3px 0}.module-kpis small{font-size:var(--shd-type-telemetry);color:#555d5f}.dashboard-editor{margin:10px 0;background:#131617;border:1px solid #4a5051;border-top:2px solid #f6a821;padding:14px 16px}.editor-title{display:flex;justify-content:space-between;align-items:start;padding-bottom:9px;border-bottom:1px solid #303536}.editor-title h3{font-size:16px;margin:3px 0}.editor-title button{border:0;background:transparent;color:#7e8587;font-size:24px;line-height:1}.dashboard-editor .form-grid{grid-template-columns:repeat(5,minmax(0,1fr));gap:9px}.dashboard-editor .field{margin:8px 0}.dashboard-editor .field input,.dashboard-editor .field select,.dashboard-editor .field textarea{padding:8px 9px;margin-top:5px;font-size:var(--shd-type-telemetry)}.range-field>div,.uptime-field>div{display:grid;grid-template-columns:1fr 1fr;gap:5px}.dashboard-table{border:1px solid #2d3233;background:#111415;overflow:auto}.dashboard-row{min-width:820px;display:grid;grid-template-columns:1.5fr 1fr .75fr .85fr .6fr .3fr;align-items:center;gap:10px;min-height:46px;padding:7px 13px;border-top:1px solid #272c2d;font-size:var(--shd-type-telemetry)}.dashboard-row:hover:not(.table-heading){background:#15191a}.dashboard-row>div{display:grid;gap:3px}.dashboard-row>div small{font-size:var(--shd-type-telemetry);color:#60686a}.dashboard-row>span{width:max-content}.dashboard-row>button{justify-self:end;border:0;border-bottom:1px solid transparent;background:transparent;color:#f6a821;font-size:var(--shd-type-telemetry);font-weight:bold;padding:5px;cursor:pointer}.dashboard-row>button:hover{border-color:#f6a821}.table-heading{min-height:32px;border-top:0;background:#0d1011;color:#697174;font-size:var(--shd-type-telemetry);letter-spacing:1.2px}.state-dot{font-size:var(--shd-type-telemetry);letter-spacing:.8px;color:#75c08c}.state-dot:before{content:"";display:inline-block;width:5px;height:5px;margin-right:6px;border-radius:50%;background:currentColor;box-shadow:0 0 6px currentColor}.dashboard-row.archived{opacity:.45}.type-offensive,.type-defensive{padding:4px 6px;border:1px solid}.type-offensive{color:#df8069;border-color:#70463c}.type-defensive{color:#70b9d6;border-color:#365e6e}.tier-grid{display:grid;grid-template-columns:1fr 1fr;gap:9px;margin-top:10px}.tier-card{min-width:0;padding:8px 11px 11px;border:1px solid #343a3b;background:#101314}.tier-card legend{padding:0 7px;color:#f6a821;font-size:var(--shd-type-telemetry);font-weight:bold;letter-spacing:1.3px}.trigger-list{max-height:130px;overflow:auto;border:1px solid #292f30}.trigger-list>div{display:grid;grid-template-columns:1fr auto;align-items:center;min-height:33px;padding:4px 7px;border-top:1px solid #252a2b;background:#121516}.trigger-list label{display:flex;align-items:center;gap:7px;font-size:var(--shd-type-telemetry)}.trigger-list input{width:auto;margin:0;accent-color:#f6a821}.trigger-list button,.effects-head button{border:0;background:transparent;color:#f6a821;font-size:var(--shd-type-telemetry)}.effects-head{display:flex;justify-content:space-between;align-items:center;margin-top:10px}.talent-effect{display:grid;grid-template-columns:1fr 90px 27px;gap:5px;margin-top:5px}.talent-effect select,.talent-effect input{min-width:0;padding:7px;margin:0;font-size:var(--shd-type-telemetry)}.talent-effect button{border:1px solid #563734;background:#1d1212;color:#da746c}.talent-identity{grid-template-columns:2fr 1fr!important}.uptime-field>span{display:flex;align-items:center;gap:7px}.uptime-field>span input{width:auto;margin:0;accent-color:#f6a821}.script-note{align-self:end;margin:8px 0;padding:9px;border:1px dashed #394041;display:grid}.script-note b{font-size:var(--shd-type-telemetry);color:#8e9597;letter-spacing:1.2px}.script-note span{font-size:var(--shd-type-telemetry);color:#5d6567;margin-top:4px}.configured{color:#79bd8d;font-size:var(--shd-type-telemetry)} -.dashboard-table>.registry-inline-editor{position:sticky;left:0;z-index:1;width:100%;background:#101314;animation:registry-editor-reveal .18s cubic-bezier(.16,1,.3,1)}.registry-inline-editor>.dashboard-editor{margin:0;border-right:0;border-left:0;background:#131617;box-sizing:border-box}.dashboard-row.registry-row-editing{background:#19170f;box-shadow:inset 2px 0 var(--shd-orange)}.dashboard-row.registry-row-editing:hover{background:#19170f}.dashboard-row.registry-row-editing>button{color:var(--shd-text);border-color:var(--shd-orange)}@keyframes registry-editor-reveal{from{opacity:.82;clip-path:inset(0 0 100% 0)}to{opacity:1;clip-path:inset(0)}}@media(max-width:700px){.registry-inline-editor>.dashboard-editor{padding:12px}}@media(prefers-reduced-motion:reduce){.dashboard-table>.registry-inline-editor{animation:none}} -@media(max-width:900px){.admin-command{align-items:flex-start}.command-status>div:nth-child(n+2){display:none}.admin-dashboard{grid-template-columns:1fr}.admin-sidebar{position:static;height:auto;min-height:0}.admin-sidebar nav{display:grid;grid-template-columns:1fr 1fr}.sidebar-foot{display:none}.module-kpis{grid-template-columns:1fr 1fr}.dashboard-editor .form-grid{grid-template-columns:1fr 1fr}.tier-grid{grid-template-columns:1fr}}@media(max-width:560px){.admin-command,.module-toolbar{flex-direction:column;align-items:flex-start;gap:12px}.module-toolbar>div:last-child{width:100%}.module-toolbar button{flex:1}.module-kpis{grid-template-columns:1fr 1fr}.dashboard-editor .form-grid{grid-template-columns:1fr}.talent-identity{grid-template-columns:1fr!important}} +.admin-page { + width: 100%; + min-height: calc(100vh - 76px); + padding: 18px clamp(14px, 2vw, 32px) 40px; + background: linear-gradient(135deg, #101314aa 0, transparent 38%); +} +.admin-command { + height: auto; + min-height: 76px; + position: relative; + top: auto; + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 0 16px; + background: transparent; + border-bottom: 1px solid #303536; +} +.command-title h1 { + margin: 4px 0 2px; + font-size: 27px; + letter-spacing: 1px; +} +.command-title > p:last-child { + margin: 0; + color: #697073; + font-size: var(--shd-type-telemetry); + letter-spacing: 1.1px; +} +.command-status { + display: flex; + gap: 7px; +} +.command-status > div { + position: relative; + min-width: 105px; + height: 47px; + padding: 9px 12px; + background: #111415; + border: 1px solid #303536; + display: grid; + align-content: center; +} +.command-status span { + font-size: var(--shd-type-telemetry); + letter-spacing: 1.3px; + color: #6f7678; +} +.command-status strong { + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; + margin-top: 3px; +} +.command-status i { + position: absolute; + right: 9px; + top: 9px; + width: 6px; + height: 6px; + border-radius: 50%; + background: #68bd82; + box-shadow: 0 0 8px #68bd82; +} +.admin-dashboard { + display: grid; + grid-template-columns: 210px minmax(0, 1fr); + gap: 12px; + margin-top: 12px; +} +.admin-sidebar { + position: sticky; + top: 88px; + min-height: 520px; + height: calc(100vh - 118px); + display: flex; + flex-direction: column; + background: #0f1213; + border: 1px solid #2c3132; +} +.sidebar-label { + height: 43px; + padding: 10px 12px; + border-bottom: 1px solid #2c3132; + display: flex; + justify-content: space-between; + align-items: center; +} +.sidebar-label span, +.sidebar-label b { + font-size: var(--shd-type-telemetry); + letter-spacing: 1.3px; +} +.sidebar-label span { + color: #8d9395; +} +.sidebar-label b { + color: #505759; +} +.admin-sidebar nav { + display: flex; + flex-direction: column; + margin: 0; + height: auto; +} +.admin-sidebar nav a { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + min-height: 49px; + padding: 0 12px 0 0; + color: #858c8e; + border: 0; + border-bottom: 1px solid #272c2d; + font-size: var(--shd-type-telemetry); +} +.admin-sidebar nav a small { + height: 100%; + display: grid; + place-items: center; + background: #141819; + color: #535a5c; + font-size: var(--shd-type-telemetry); + letter-spacing: 0.6px; +} +.admin-sidebar nav a span { + padding-left: 12px; +} +.admin-sidebar nav a b { + font-size: 17px; + color: #f6a821; +} +.admin-sidebar nav a em { + font-style: normal; + font-size: var(--shd-type-telemetry); + color: #555c5e; + letter-spacing: 1px; +} +.admin-sidebar nav a:hover, +.admin-sidebar nav a.active { + color: #fff; + background: #151819; +} +.admin-sidebar nav a.active { + box-shadow: inset 2px 0 #f6a821; +} +.admin-sidebar nav a.active small { + background: #2b2111; + color: #f6a821; +} +.admin-sidebar nav a.pending { + opacity: 0.5; + pointer-events: none; +} +.sidebar-foot { + margin-top: auto; + padding: 14px; + border-top: 1px solid #2c3132; + display: grid; +} +.sidebar-foot span { + font-size: var(--shd-type-telemetry); + color: #5e6668; + letter-spacing: 1.3px; +} +.sidebar-foot strong { + font-size: 14px; + color: #f6a821; + margin: 4px 0; +} +.sidebar-foot small { + font-size: var(--shd-type-telemetry); + color: #596164; +} +.admin-content { + min-width: 0; +} +.module-toolbar { + min-height: 63px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 2px 12px; + border-bottom: 1px solid #303536; +} +.module-toolbar h2 { + font-size: 19px; + letter-spacing: 0.4px; + margin: 3px 0; +} +.module-toolbar p { + margin: 0; +} +.module-toolbar > div > p:last-child { + font-size: var(--shd-type-telemetry); + color: #71787a; +} +.module-toolbar > div:last-child { + display: flex; + gap: 8px; +} +.module-toolbar .primary, +.module-toolbar .secondary { + height: 40px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 15px; +} +.module-kpis { + display: grid; + grid-template-columns: repeat(4, minmax(120px, 1fr)); + gap: 8px; + margin: 10px 0; +} +.module-kpis > div { + position: relative; + min-height: 70px; + padding: 11px 13px; + background: #111415; + border: 1px solid #2d3233; + border-top: 2px solid #3a4041; + display: grid; +} +.module-kpis > div:first-child { + border-top-color: #f6a821; +} +.module-kpis span { + font-size: var(--shd-type-telemetry); + letter-spacing: 1.3px; + color: #737a7c; +} +.module-kpis strong { + font-size: 23px; + line-height: 1.1; + margin: 3px 0; +} +.module-kpis small { + font-size: var(--shd-type-telemetry); + color: #555d5f; +} +.dashboard-editor { + margin: 10px 0; + background: #131617; + border: 1px solid #4a5051; + border-top: 2px solid #f6a821; + padding: 14px 16px; +} +.editor-title { + display: flex; + justify-content: space-between; + align-items: start; + padding-bottom: 9px; + border-bottom: 1px solid #303536; +} +.editor-title h3 { + font-size: 16px; + margin: 3px 0; +} +.editor-title button { + border: 0; + background: transparent; + color: #7e8587; + font-size: 24px; + line-height: 1; +} +.dashboard-editor .form-grid { + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 9px; +} +.dashboard-editor .field { + margin: 8px 0; +} +.dashboard-editor .field input, +.dashboard-editor .field select, +.dashboard-editor .field textarea { + padding: 8px 9px; + margin-top: 5px; + font-size: var(--shd-type-telemetry); +} +.range-field > div, +.uptime-field > div { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 5px; +} +.dashboard-table { + border: 1px solid #2d3233; + background: #111415; + overflow: auto; +} +.dashboard-row { + min-width: 820px; + display: grid; + grid-template-columns: 1.5fr 1fr 0.75fr 0.85fr 0.6fr 0.3fr; + align-items: center; + gap: 10px; + min-height: 46px; + padding: 7px 13px; + border-top: 1px solid #272c2d; + font-size: var(--shd-type-telemetry); +} +.dashboard-row:hover:not(.table-heading) { + background: #15191a; +} +.dashboard-row > div { + display: grid; + gap: 3px; +} +.dashboard-row > div small { + font-size: var(--shd-type-telemetry); + color: #60686a; +} +.dashboard-row > span { + width: max-content; +} +.dashboard-row > button { + justify-self: end; + border: 0; + border-bottom: 1px solid transparent; + background: transparent; + color: #f6a821; + font-size: var(--shd-type-telemetry); + font-weight: bold; + padding: 5px; + cursor: pointer; +} +.dashboard-row > button:hover { + border-color: #f6a821; +} +.table-heading { + min-height: 32px; + border-top: 0; + background: #0d1011; + color: #697174; + font-size: var(--shd-type-telemetry); + letter-spacing: 1.2px; +} +.state-dot { + font-size: var(--shd-type-telemetry); + letter-spacing: 0.8px; + color: #75c08c; +} +.state-dot:before { + content: ''; + display: inline-block; + width: 5px; + height: 5px; + margin-right: 6px; + border-radius: 50%; + background: currentColor; + box-shadow: 0 0 6px currentColor; +} +.dashboard-row.archived { + opacity: 0.45; +} +.type-offensive, +.type-defensive { + padding: 4px 6px; + border: 1px solid; +} +.type-offensive { + color: #df8069; + border-color: #70463c; +} +.type-defensive { + color: #70b9d6; + border-color: #365e6e; +} +.tier-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 9px; + margin-top: 10px; +} +.tier-card { + min-width: 0; + padding: 8px 11px 11px; + border: 1px solid #343a3b; + background: #101314; +} +.tier-card legend { + padding: 0 7px; + color: #f6a821; + font-size: var(--shd-type-telemetry); + font-weight: bold; + letter-spacing: 1.3px; +} +.trigger-list { + max-height: 130px; + overflow: auto; + border: 1px solid #292f30; +} +.trigger-list > div { + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + min-height: 33px; + padding: 4px 7px; + border-top: 1px solid #252a2b; + background: #121516; +} +.trigger-list label { + display: flex; + align-items: center; + gap: 7px; + font-size: var(--shd-type-telemetry); +} +.trigger-list input { + width: auto; + margin: 0; + accent-color: #f6a821; +} +.trigger-list button, +.effects-head button { + border: 0; + background: transparent; + color: #f6a821; + font-size: var(--shd-type-telemetry); +} +.effects-head { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 10px; +} +.talent-effect { + display: grid; + grid-template-columns: 1fr 90px 27px; + gap: 5px; + margin-top: 5px; +} +.talent-effect select, +.talent-effect input { + min-width: 0; + padding: 7px; + margin: 0; + font-size: var(--shd-type-telemetry); +} +.talent-effect button { + border: 1px solid #563734; + background: #1d1212; + color: #da746c; +} +.talent-identity { + grid-template-columns: 2fr 1fr !important; +} +.uptime-field > span { + display: flex; + align-items: center; + gap: 7px; +} +.uptime-field > span input { + width: auto; + margin: 0; + accent-color: #f6a821; +} +.script-note { + align-self: end; + margin: 8px 0; + padding: 9px; + border: 1px dashed #394041; + display: grid; +} +.script-note b { + font-size: var(--shd-type-telemetry); + color: #8e9597; + letter-spacing: 1.2px; +} +.script-note span { + font-size: var(--shd-type-telemetry); + color: #5d6567; + margin-top: 4px; +} +.configured { + color: #79bd8d; + font-size: var(--shd-type-telemetry); +} +.dashboard-table > .registry-inline-editor { + position: sticky; + left: 0; + z-index: 1; + width: 100%; + background: #101314; + animation: registry-editor-reveal 0.18s cubic-bezier(0.16, 1, 0.3, 1); +} +.registry-inline-editor > .dashboard-editor { + margin: 0; + border-right: 0; + border-left: 0; + background: #131617; + box-sizing: border-box; +} +.dashboard-row.registry-row-editing { + background: #19170f; + box-shadow: inset 2px 0 var(--shd-orange); +} +.dashboard-row.registry-row-editing:hover { + background: #19170f; +} +.dashboard-row.registry-row-editing > button { + color: var(--shd-text); + border-color: var(--shd-orange); +} +@keyframes registry-editor-reveal { + from { + opacity: 0.82; + clip-path: inset(0 0 100% 0); + } + to { + opacity: 1; + clip-path: inset(0); + } +} +@media (max-width: 700px) { + .registry-inline-editor > .dashboard-editor { + padding: 12px; + } +} +@media (prefers-reduced-motion: reduce) { + .dashboard-table > .registry-inline-editor { + animation: none; + } +} +@media (max-width: 900px) { + .admin-command { + align-items: flex-start; + } + .command-status > div:nth-child(n + 2) { + display: none; + } + .admin-dashboard { + grid-template-columns: 1fr; + } + .admin-sidebar { + position: static; + height: auto; + min-height: 0; + } + .admin-sidebar nav { + display: grid; + grid-template-columns: 1fr 1fr; + } + .sidebar-foot { + display: none; + } + .module-kpis { + grid-template-columns: 1fr 1fr; + } + .dashboard-editor .form-grid { + grid-template-columns: 1fr 1fr; + } + .tier-grid { + grid-template-columns: 1fr; + } +} +@media (max-width: 560px) { + .admin-command, + .module-toolbar { + flex-direction: column; + align-items: flex-start; + gap: 12px; + } + .module-toolbar > div:last-child { + width: 100%; + } + .module-toolbar button { + flex: 1; + } + .module-kpis { + grid-template-columns: 1fr 1fr; + } + .dashboard-editor .form-grid { + grid-template-columns: 1fr; + } + .talent-identity { + grid-template-columns: 1fr !important; + } +} /* Shared SHD routed surfaces */ -.site-header .account small i{display:inline-block;width:5px;height:5px;border-radius:50%;background:var(--shd-success);box-shadow:0 0 6px var(--shd-success);margin-right:3px} -.catalogue-page,.guides-page,.profile-page{padding-top:34px}.catalogue-hero{min-height:230px;display:flex;justify-content:space-between;align-items:center;padding:25px 32px;margin-bottom:12px;border:1px solid var(--shd-border);background:radial-gradient(circle at 80% 50%,#332714 0,transparent 30%),linear-gradient(120deg,#141718,#0d1011);clip-path:polygon(0 0,97% 0,100% 18%,100% 100%,3% 100%,0 82%)}.catalogue-hero h1{font-size:clamp(48px,6vw,82px);line-height:.82;letter-spacing:-3px;margin:14px 0}.catalogue-hero h1 em{font-style:normal;color:var(--shd-orange)}.catalogue-hero>div>p:last-child{max-width:580px;color:var(--shd-text-muted);font:15px/1.55 var(--shd-font-editorial)}.catalogue-count{min-width:210px;display:grid;text-align:right}.catalogue-count strong{font-size:38px}.catalogue-count span{font-size:var(--shd-type-telemetry);color:#6d7476;letter-spacing:1.5px;margin:3px 0 18px}.catalogue-count .primary{justify-self:end}.filter-bar{display:grid;grid-template-columns:2fr 1fr 1fr auto;gap:7px;align-items:end;margin:12px 0 18px;padding:11px;background:#101314;border:1px solid #2d3233}.filter-bar label{font-size:var(--shd-type-telemetry);color:#72797b;letter-spacing:1.2px}.filter-bar input,.filter-bar select{height:38px;margin-top:6px;padding:0 10px;font-size:var(--shd-type-telemetry)}.filter-bar button{height:38px;padding:0 14px}.catalogue-grid{max-width:none}.catalogue-grid article{position:relative}.build-open{width:100%;margin-top:15px;padding:10px;border:1px solid #3a4041;background:#101314;color:#f6a821;font-size:var(--shd-type-telemetry);font-weight:bold;letter-spacing:1px}.guide-layout{display:grid;grid-template-columns:1fr 330px;gap:12px}.guide-feature{min-height:360px;padding:35px;position:relative;border:1px solid #303536;border-top:2px solid #f6a821;background:linear-gradient(135deg,#151819,#0d1011)}.guide-code{position:absolute;right:25px;top:22px;color:#4f5658;font-size:var(--shd-type-telemetry);letter-spacing:1.5px}.guide-feature h2{font-size:42px;line-height:.95;margin:20px 0}.guide-feature>p:not(.eyebrow){max-width:650px;color:#858b8d;font:15px/1.6 var(--shd-font-editorial)}.guide-meta{display:flex;gap:8px;position:absolute;left:35px;bottom:30px}.guide-meta span{display:grid;padding:8px 11px;border:1px solid #303536;color:#60686a;font-size:var(--shd-type-telemetry);letter-spacing:1px}.guide-meta b{color:#d7d9d5;font-size:var(--shd-type-telemetry);margin-top:4px}.guide-index{border:1px solid #303536;background:#111415;padding:20px}.guide-index>div{display:grid;grid-template-columns:30px 1fr auto;align-items:center;min-height:54px;border-top:1px solid #292e2f}.guide-index small{color:#535b5d;font-size:var(--shd-type-telemetry)}.guide-index span{font-size:var(--shd-type-telemetry);letter-spacing:.8px}.guide-index b{color:#f6a821} -.profile-command{display:flex;justify-content:space-between;align-items:center;padding:0 0 24px;border-bottom:1px solid #303536}.profile-ident{display:flex;align-items:center;gap:18px}.profile-avatar{width:76px;height:76px;display:block;object-fit:cover;border:1px solid #f6a821;border-radius:50%;background:#0a0c0d;box-shadow:0 0 0 6px #0a0c0d,0 0 0 7px #f6a82155}.profile-ident h1{font-size:39px;margin:4px 0}.role-badge{display:inline-block;padding:4px 8px;border:1px solid #6e5226;color:#f6a821;font-size:var(--shd-type-telemetry);letter-spacing:1.3px}.profile-metrics{display:flex;gap:7px}.profile-metrics div{min-width:77px;height:59px;display:grid;place-items:center;padding:7px;background:#111415;border:1px solid #303536}.profile-metrics strong{font-size:20px}.profile-metrics span{font-size:var(--shd-type-telemetry);color:#697174;letter-spacing:1px}.account-alert{display:flex;gap:16px;margin:12px 0;padding:12px 15px;background:#2a2112;border:1px solid #795c2a;border-left:3px solid #f6a821;font-size:var(--shd-type-telemetry)}.account-alert b{color:#f6a821}.account-alert span{color:#b8ad99}.profile-dashboard{display:grid;grid-template-columns:minmax(0,1fr) 340px;gap:12px;margin-top:12px}.profile-feed{display:grid;gap:12px}.profile-module,.account-console{background:#111415;border:1px solid #303536;padding:16px}.module-head{display:flex;justify-content:space-between;align-items:center}.module-head h2,.profile-module h2,.account-console h2{font-size:17px;margin:4px 0 12px}.module-head .secondary{padding:10px 12px}.profile-table{border:1px solid #292e2f}.profile-row{display:grid;grid-template-columns:1.5fr .7fr .7fr .3fr;align-items:center;gap:9px;min-height:47px;padding:7px 11px;border-top:1px solid #292e2f;font-size:var(--shd-type-telemetry)}.profile-row.heading{min-height:30px;border:0;background:#0d1011;color:#636b6d;font-size:var(--shd-type-telemetry);letter-spacing:1px}.profile-row>div{display:grid}.profile-row small{font-size:var(--shd-type-telemetry);color:#596164}.profile-row>span{text-transform:uppercase;color:#858d8f}.profile-row button{border:0;background:transparent;color:#f6a821;font-size:var(--shd-type-telemetry)}.profile-empty{text-align:center;padding:38px}.profile-empty>span{font-size:31px;color:#f6a821}.profile-empty b{display:block;font-size:var(--shd-type-telemetry);letter-spacing:1px;margin:10px}.profile-empty p{color:#71797b;font:13px var(--shd-font-editorial)}.profile-empty a{color:#f6a821;font-size:var(--shd-type-telemetry);font-weight:bold}.profile-columns{display:grid;grid-template-columns:1fr 1fr;gap:12px}.large-count{font-size:32px}.count-label{display:block;font-size:var(--shd-type-telemetry);color:#687073;letter-spacing:1.2px}.account-console{height:max-content}.console-head{display:flex;justify-content:space-between;padding-bottom:11px;margin-bottom:15px;border-bottom:1px solid #303536;color:#747c7e;font-size:var(--shd-type-telemetry);letter-spacing:1.3px}.console-head i{width:6px;height:6px;border-radius:50%;background:#75c08c;box-shadow:0 0 6px #75c08c}.console-action{width:100%;margin-top:9px}.connection-card{display:grid;grid-template-columns:35px 1fr auto;align-items:center;padding:10px;border:1px solid #303536}.discord-node{color:#8993ff;font-size:21px}.connection-card div{display:grid}.connection-card b{font-size:var(--shd-type-telemetry)}.connection-card small{font-size:var(--shd-type-telemetry);color:#656d6f;margin-top:3px}.connection-card a{color:#f6a821;font-size:var(--shd-type-telemetry);font-weight:bold} -.avatar-picker{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin-bottom:10px}.avatar-preset-form,.avatar-preset-form fieldset{min-width:0;height:100%}.avatar-preset{display:grid;gap:5px;width:100%;height:100%;min-width:0;padding:4px;background:#0b0e0f;border:1px solid #303536;color:#747c7e}.avatar-preset:hover,.avatar-preset:focus-visible{border-color:#8a672b;color:#f6a821}.avatar-preset.selected{border-color:#f6a821;color:#f6a821;box-shadow:inset 0 -2px #f6a821}.avatar-preset img{display:block;width:100%;aspect-ratio:1;object-fit:cover}.avatar-preset span{min-width:0;overflow-wrap:anywhere;font-size:var(--shd-type-telemetry);line-height:1.25;text-transform:uppercase;letter-spacing:.4px}.avatar-upload{min-width:0;margin-top:8px}.account-console .image-drop-input{grid-template-columns:64px minmax(0,1fr)}.account-console .image-upload-actions{grid-column:1/-1;display:grid;grid-template-columns:1fr 1fr;width:100%}.account-console .image-upload-actions button{min-width:0} -.image-drop-input{display:grid;grid-template-columns:64px 1fr auto;align-items:center;gap:10px;min-height:82px;padding:8px;border:1px dashed #4b5152;background:#0b0e0f;cursor:pointer;transition:border-color .15s,background .15s,transform .1s}.image-drop-input:hover,.image-drop-input:focus-visible,.image-drop-input.dragging{border-color:var(--shd-orange);background:#15130e;outline:none}.image-drop-input.dragging{transform:translateY(1px);box-shadow:inset 0 0 0 1px #f6a82155}.image-file-input{position:absolute!important;width:1px!important;height:1px!important;overflow:hidden!important;clip:rect(0 0 0 0)!important;white-space:nowrap!important}.image-upload-preview{display:grid;place-items:center;width:64px;height:64px;border:1px solid #343a3b;background:#070909;color:#62696b;font-size:var(--shd-type-telemetry);text-align:center}.image-upload-preview img{display:block;width:100%;height:100%;object-fit:contain}.image-drop-copy{display:grid;gap:4px;min-width:0}.image-drop-copy b{color:#c6cbcc;font-size:var(--shd-type-telemetry);letter-spacing:1px}.image-drop-copy span{overflow:hidden;color:#747c7e;font-size:var(--shd-type-telemetry);text-overflow:ellipsis;white-space:nowrap}.image-upload-actions{display:flex;gap:5px}.image-upload-actions button{min-width:92px;padding:8px}.image-upload-error{display:block;margin-top:5px;color:var(--shd-danger)}@media(max-width:700px){.image-drop-input{grid-template-columns:56px 1fr}.image-upload-preview{width:56px;height:56px}.image-upload-actions{grid-column:1/-1}.image-upload-actions button{flex:1}} -.icon-upload-field .image-drop-input{grid-template-columns:1fr;gap:6px;text-align:center}.icon-upload-field .image-upload-preview{width:100%;height:72px}.icon-upload-field .image-upload-preview img{width:64px;height:64px}.icon-upload-field .image-upload-actions{display:grid;grid-template-columns:1fr 1fr;width:100%}.icon-upload-field .image-upload-actions button{min-width:0;padding:7px 3px;font-size:var(--shd-type-telemetry)} -.game-data-render-upload{margin:8px 0;padding:8px;border:1px solid var(--shd-border);background:#0d1011}.game-data-render-upload legend{padding:0 5px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.game-data-render-upload .image-drop-input{grid-template-columns:84px minmax(0,1fr) auto}.game-data-render-upload .image-upload-preview{width:84px;height:84px}.game-data-render-upload .image-upload-preview img{object-fit:cover}.game-data-render-upload .image-drop-copy{justify-self:stretch;text-align:center}.game-data-render-upload .render-remove{display:flex;gap:6px;align-items:center;margin-top:6px;color:var(--shd-danger);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.6px}.game-data-render-upload .render-remove input{width:auto;margin:0;accent-color:var(--shd-danger)}.game-data-render-upload>small{display:block;margin-top:6px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.35} -.dashboard-row>.registry-render-identity{display:flex;align-items:center;gap:8px;min-width:0}.registry-render-identity>div{display:grid;gap:2px;min-width:0}.registry-render{width:42px;height:42px;flex:0 0 auto;object-fit:cover;border:1px solid var(--shd-border);background:#090b0c} -.database-item-card header:has(.database-item-render){grid-template-columns:64px 42px minmax(0,1fr) auto}.database-item-render{display:block;width:64px;height:64px;object-fit:cover;border:1px solid var(--shd-border);background:#090b0c} -.proposal-submitted,.data-access-banner{display:flex;align-items:center;gap:12px;margin-bottom:9px;padding:9px 11px;border-left:3px solid var(--shd-success);background:#101713;color:#a6cbb0}.proposal-submitted b,.data-access-banner b{font-size:var(--shd-type-telemetry);letter-spacing:1px}.proposal-submitted span,.data-access-banner span{font-size:var(--shd-type-telemetry);color:#7e9d86}.data-access-banner{border-left-color:#657174;background:#111617;color:#aeb7b9}.data-access-banner.contributor{border-left-color:var(--shd-orange);background:#19150e;color:#d8bc87}.data-access-error{margin:-3px 0 9px}.data-readonly form[method=post] button[type=submit],.data-readonly form[method=post] button:not([type]){filter:grayscale(1);opacity:.45}.admin-sidebar nav a.has-pending{grid-template-columns:minmax(0,1fr) 18px 18px}.admin-sidebar nav a.has-pending em{grid-column:2;display:grid;place-items:center;min-width:18px;height:18px;padding:0 5px;border:1px solid #a26c19;background:#211a0e;color:#ffc45d;font-size:var(--shd-type-telemetry);font-style:normal;font-weight:900;text-align:center}.admin-sidebar nav a.has-pending b{grid-column:3;justify-self:end}.admin-sidebar nav a.has-pending:hover em,.admin-sidebar nav a.has-pending.active em{border-color:var(--shd-orange);background:#2b2111;color:#fff1d0}.moderation-row{grid-template-columns:minmax(240px,1.5fr) minmax(140px,.8fr) 150px 80px 75px}.moderation-review{width:min(1120px,100%);max-height:calc(100vh - 32px);overflow:auto}.moderation-metadata{display:grid;grid-template-columns:repeat(4,1fr);gap:6px;margin:10px 0}.moderation-metadata span{display:grid;gap:4px;padding:8px;border:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry)}.moderation-metadata b{color:var(--shd-text);font-size:var(--shd-type-telemetry)}.moderation-review details{margin:10px 0;border:1px solid var(--shd-border);background:#0b0e0f}.moderation-review summary{padding:9px;color:var(--shd-orange);font-size:var(--shd-type-telemetry);cursor:pointer}.moderation-review pre{max-height:260px;overflow:auto;margin:0;padding:10px;border-top:1px solid var(--shd-border);color:#9ca6a8;font-size:var(--shd-type-telemetry);white-space:pre-wrap}.proposal-change-preview{margin-top:10px}.proposal-preview-head{display:flex;align-items:center;justify-content:space-between;padding:8px 10px;border:1px solid var(--shd-border);background:#0d1011}.proposal-preview-head>div{display:grid;gap:3px}.proposal-preview-head span{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1px}.proposal-preview-head small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry)}.proposal-preview-head>b{color:var(--shd-orange);font-size:var(--shd-type-telemetry)}.proposal-compare-grid{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin-top:7px}.proposal-editor-preview{min-width:0;border:1px solid var(--shd-border);background:#111415}.proposal-editor-preview.before{border-top:2px solid #596164}.proposal-editor-preview.after{border-top:2px solid var(--shd-orange)}.proposal-editor-preview>header{display:flex;align-items:center;justify-content:space-between;min-height:32px;padding:6px 9px;border-bottom:1px solid var(--shd-border);background:#0d1011}.proposal-editor-preview>header span,.proposal-editor-preview>header b{font-size:var(--shd-type-telemetry);letter-spacing:.9px}.proposal-editor-preview>header b{color:var(--shd-text-muted)}.proposal-editor-preview.after>header b{color:var(--shd-orange)}.proposal-attribute-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;padding:8px}.preview-field{min-width:0;display:grid;align-content:start;gap:5px}.preview-field.identity{grid-column:span 4}.preview-field>span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.preview-field>strong,.preview-field.range>div{min-height:34px;display:flex;align-items:center;padding:7px 8px;border:1px solid #363b3c;background:#0d0f10;color:var(--shd-text);font-size:var(--shd-type-telemetry);overflow-wrap:anywhere}.preview-field.identity>strong{min-height:43px;font-size:13px}.preview-field.range{grid-column:span 2}.preview-field.range>div{display:grid;grid-template-columns:1fr auto 1fr;gap:6px}.preview-field.range strong:last-child{text-align:right}.preview-field.range i{color:#555d5f;font-size:var(--shd-type-telemetry);font-style:normal}.proposal-empty-state{min-height:180px;display:grid;place-content:center;gap:6px;text-align:center}.proposal-empty-state b{color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:1px}.proposal-empty-state span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry)}.proposal-editor-preview.generic{margin-top:7px}.proposal-field-summary{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px;padding:8px}.proposal-field-summary>div{min-width:0;display:grid;align-content:start;gap:5px;padding:8px;border:1px solid #303536;background:#0d0f10}.proposal-field-summary span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.proposal-field-summary strong{color:var(--shd-text);font-size:var(--shd-type-telemetry);overflow-wrap:anywhere}.proposal-field-summary small{max-height:42px;overflow:hidden;color:#687174;font-size:var(--shd-type-telemetry);overflow-wrap:anywhere}.decision-tabs{display:grid;grid-template-columns:1fr 1fr;gap:5px;margin:10px 0}.decision-tabs button{padding:9px;border:1px solid var(--shd-border);background:#111415;color:var(--shd-text-muted)}.decision-tabs button.active{border-color:var(--shd-orange);color:var(--shd-orange)}@media(max-width:800px){.moderation-row{grid-template-columns:1fr}.moderation-metadata,.proposal-compare-grid{grid-template-columns:1fr}.proposal-field-summary{grid-template-columns:1fr 1fr}}@media(max-width:520px){.proposal-attribute-grid,.proposal-field-summary{grid-template-columns:1fr}.preview-field.identity,.preview-field.range{grid-column:auto}} -.account-tabs{width:100%;height:auto;margin:0 0 16px;display:grid;grid-template-columns:repeat(3,1fr);border:1px solid var(--shd-border)}.account-tabs button,.account-subtabs button{min-width:0;height:34px;padding:0 5px;border:0;border-left:1px solid var(--shd-border);border-bottom:2px solid transparent;background:#0d1011;color:#6f7779;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px;cursor:pointer;transition:var(--shd-transition)}.account-tabs button:first-child,.account-subtabs button:first-child{border-left:0}.account-tabs button:hover,.account-tabs button:focus-visible,.account-subtabs button:hover,.account-subtabs button:focus-visible{color:var(--shd-text);background:#171b1c}.account-tabs button.active,.account-subtabs button.active{border-bottom-color:var(--shd-orange);background:#211a0e;color:var(--shd-orange)}.account-tabs button:active,.account-subtabs button:active{transform:translateY(1px);filter:brightness(.85)}.account-tabs button:focus-visible,.account-subtabs button:focus-visible{position:relative;z-index:1;outline:2px solid var(--shd-orange);outline-offset:2px}.account-panel{min-height:290px}.account-subtabs{display:grid;grid-template-columns:1fr 1fr;margin:0 0 10px;border:1px solid var(--shd-border)}.account-subtabs button{height:31px}.access-subtabs{display:grid;grid-template-columns:repeat(4,1fr);margin:-2px 0 17px;border:1px solid var(--shd-border);background:#0b0e0f}.access-subtabs button{min-width:0;height:42px;display:grid;place-content:center;gap:3px;padding:4px;border:0;border-left:1px solid var(--shd-border);border-bottom:2px solid transparent;background:transparent;color:var(--shd-text-dim);cursor:pointer;transition:var(--shd-transition)}.access-subtabs button:first-child{border-left:0}.access-subtabs button span{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.access-subtabs button b{font-size:var(--shd-type-telemetry)}.access-subtabs button:hover,.access-subtabs button:focus-visible{background:#171b1c;color:var(--shd-text)}.access-subtabs button.active{border-bottom-color:var(--shd-orange);background:#211a0e;color:var(--shd-orange)}.access-subtabs button:active{transform:translateY(1px);filter:brightness(.85)}.access-subtabs button:focus-visible{position:relative;z-index:1;outline:2px solid var(--shd-orange);outline-offset:2px}.account-retention{color:var(--shd-text-muted);font:12px/1.55 var(--shd-font-editorial)}.danger-eyebrow{color:var(--shd-danger)} -.account-alert.error-alert{background:#261513;border-color:#75443b;border-left-color:#df8069}.account-alert.error-alert b{color:#df8069}.account-alert.error-alert span{color:#d2aaa1} -.danger-zone{margin-top:12px;border:1px solid #563734;background:#181112}.danger-zone summary{padding:10px;color:#df8069;cursor:pointer;font-size:var(--shd-type-telemetry);font-weight:bold;letter-spacing:1px}.danger-zone>p,.danger-zone>form{margin:0;padding:0 10px 10px}.danger-zone>p{color:#9f8580;font-size:var(--shd-type-telemetry);line-height:1.5}.danger-zone form{padding-bottom:10px} -.auth-page{display:block;padding:55px clamp(20px,5vw,80px)}.auth-frame{width:min(980px,100%);min-height:520px;margin:auto;display:grid;grid-template-columns:1.15fr .85fr;border:1px solid #34393a;background:#101314;clip-path:polygon(0 0,96% 0,100% 7%,100% 100%,4% 100%,0 93%)}.auth-intel{width:auto!important;padding:65px!important;border:0!important;border-right:1px solid #303536!important;background:radial-gradient(circle at 15% 80%,#392b15 0,transparent 33%),#0e1112!important}.auth-intel h1{font-size:66px!important;line-height:.85;letter-spacing:-3px;margin:24px 0}.auth-intel h1 em{font-style:normal;color:#f6a821}.auth-intel>p:not(.eyebrow){max-width:470px;color:#909698;font:15px/1.6 var(--shd-font-editorial)}.auth-status{display:flex;justify-content:space-between;margin-top:55px;padding-top:14px;border-top:1px solid #303536;color:#626a6c;font-size:var(--shd-type-telemetry);letter-spacing:1.2px}.auth-status i{display:inline-block;width:5px;height:5px;border-radius:50%;background:#75c08c;box-shadow:0 0 6px #75c08c;margin-right:5px}.auth-console{width:auto!important;padding:34px!important;border:0!important;background:#141718!important}.auth-console .console-head b{color:#f6a821}.auth-console .discord{min-height:48px;margin-top:18px;padding:10px 18px;display:flex;align-items:center;justify-content:center;gap:14px;background:#5865f2;color:#fff;border:0;border-radius:4px;font-size:15px;font-weight:600;letter-spacing:0;text-transform:none}.auth-console .discord img{display:block;width:27px;height:21px;object-fit:contain;flex:0 0 auto}.auth-console .discord:hover{background:#4752c4;filter:none}.auth-console .discord:active{background:#3c45a5;filter:none}.auth-console .discord:focus-visible{outline-color:#e0e3ff}.auth-console small{margin-top:18px} -.signin-provider-rail{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:7px}.signin-provider{position:relative;min-width:0;min-height:76px;margin:0;padding:8px 4px 7px;display:grid;grid-template-rows:34px auto;place-items:center;gap:5px;border:1px solid #424849;border-radius:0;background:#101314;color:#dfe1e2;font-family:var(--shd-font-sans);font-size:var(--shd-type-telemetry);font-style:normal;font-weight:800;letter-spacing:.3px;text-transform:none;cursor:pointer;transition:var(--shd-transition)}.signin-provider-icon{width:34px;height:34px;display:grid;place-items:center}.signin-provider-icon img{display:block;width:21px;height:21px;object-fit:contain}.signin-provider em{position:absolute;right:3px;top:3px;color:#737a7c;font-size:var(--shd-type-telemetry);font-style:normal;font-weight:900;letter-spacing:.5px;text-transform:uppercase}.signin-provider:hover:not(:disabled){transform:translateY(-1px);filter:brightness(1.12)}.signin-provider:active:not(:disabled){transform:translateY(2px) scale(.97)}.signin-provider:focus-visible{outline:2px solid var(--shd-orange);outline-offset:2px}.signin-provider:disabled{cursor:not-allowed;opacity:.46;filter:saturate(.55)}.discord-provider{border-color:#5865f2;background:#5865f2;color:#fff}.discord-provider:hover{background:#4752c4;filter:none!important}.discord-provider .signin-provider-icon img{width:25px;height:19px}.google-signin-provider{border-color:#8e918f;background:#131314;color:#e3e3e3}.google-signin-provider .signin-provider-icon{background:#fff}.apple-signin-provider{border-color:#73777a;background:#000;color:#fff}.apple-signin-provider .signin-provider-icon img{width:22px;height:22px}.steam-signin-provider{border-color:#66c0f4;background:#171d25;color:#fff}.steam-signin-provider .signin-provider-icon{color:#fff}.steam-signin-provider .signin-provider-icon img{width:25px;height:25px} -.auth-mode-note{margin:-3px 0 14px;color:#7b8385;font-size:var(--shd-type-telemetry);line-height:1.5}.auth-mode-switch{display:grid;grid-template-columns:1fr 1fr;margin:12px 0 4px;border:1px solid #383d3e}.auth-mode-switch button{height:34px;border:0;border-left:1px solid #383d3e;background:#0d1011;color:#747c7e;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1px;cursor:pointer}.auth-mode-switch button:first-child{border-left:0}.auth-mode-switch button.active{background:#2b2111;color:var(--shd-orange);box-shadow:inset 0 -2px var(--shd-orange)}.auth-mode-switch button:focus-visible{outline:2px solid var(--shd-orange);outline-offset:2px} -.auth-page .auth-console form .full + .identity-note{margin-top:-14px} -.passkey-signin{position:relative;min-height:48px;margin-top:18px}.passkey-signin[aria-busy=true]{padding-right:42px}.passkey-signin[aria-busy=true]:after{content:"";position:absolute;right:16px;width:12px;height:12px;border:2px solid #111;border-right-color:transparent;border-radius:50%;animation:interaction-spin .65s linear infinite}.auth-note{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry)}.password-fallback{margin:18px 0;border:1px solid var(--shd-border);background:#101314}.password-fallback summary{padding:12px;color:#8e9698;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1px;cursor:pointer}.password-fallback summary:hover{color:var(--shd-orange)}.password-fallback form{padding:0 12px 12px}.password-fallback .full{margin-bottom:0} -.builder-page{width:100%;min-width:0;padding-top:28px}.builder-command{margin-bottom:14px;border-bottom:1px solid #303536}.builder-command .page-title{margin-bottom:12px}.builder-status{display:flex;gap:24px;padding:9px 2px;color:#61696b;font-size:var(--shd-type-telemetry);letter-spacing:1.2px}.builder-status i{display:inline-block;width:5px;height:5px;border-radius:50%;background:#75c08c;box-shadow:0 0 6px #75c08c;margin-right:5px}.builder-page .slot-card{position:relative;overflow:hidden}.builder-page .slot-card:after{content:"";position:absolute;right:-24px;bottom:-24px;width:52px;height:52px;border:1px solid #f6a82122;transform:rotate(45deg)}.builder-page .summary{border-top:2px solid #f6a821} -@media(max-width:900px){.filter-bar{grid-template-columns:1fr 1fr}.catalogue-hero{align-items:flex-start}.guide-layout,.profile-dashboard,.auth-frame{grid-template-columns:1fr}.profile-command{align-items:flex-start}.profile-metrics{display:grid;grid-template-columns:1fr 1fr}.account-console{order:-1}.auth-intel{border-right:0!important;border-bottom:1px solid #303536!important;padding:42px!important}.auth-intel h1{font-size:52px!important}}@media(max-width:560px){.catalogue-hero,.profile-command{flex-direction:column;align-items:flex-start}.catalogue-count{text-align:left;margin-top:20px}.catalogue-count .primary{justify-self:start}.filter-bar{grid-template-columns:1fr}.guide-meta{position:static;margin-top:25px;flex-wrap:wrap}.profile-metrics{width:100%;margin-top:20px}.profile-columns{grid-template-columns:1fr}.auth-page{padding:25px 14px}.auth-intel,.auth-console{padding:28px!important}} -@media(max-width:420px){.signin-provider-rail{grid-template-columns:repeat(2,minmax(0,1fr))}} +.site-header .account small i { + display: inline-block; + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--shd-success); + box-shadow: 0 0 6px var(--shd-success); + margin-right: 3px; +} +.catalogue-page, +.guides-page, +.profile-page { + padding-top: 34px; +} +.catalogue-hero { + min-height: 230px; + display: flex; + justify-content: space-between; + align-items: center; + padding: 25px 32px; + margin-bottom: 12px; + border: 1px solid var(--shd-border); + background: + radial-gradient(circle at 80% 50%, #332714 0, transparent 30%), + linear-gradient(120deg, #141718, #0d1011); + clip-path: polygon(0 0, 97% 0, 100% 18%, 100% 100%, 3% 100%, 0 82%); +} +.catalogue-hero h1 { + font-size: clamp(48px, 6vw, 82px); + line-height: 0.82; + letter-spacing: -3px; + margin: 14px 0; +} +.catalogue-hero h1 em { + font-style: normal; + color: var(--shd-orange); +} +.catalogue-hero > div > p:last-child { + max-width: 580px; + color: var(--shd-text-muted); + font: 15px/1.55 var(--shd-font-editorial); +} +.catalogue-count { + min-width: 210px; + display: grid; + text-align: right; +} +.catalogue-count strong { + font-size: 38px; +} +.catalogue-count span { + font-size: var(--shd-type-telemetry); + color: #6d7476; + letter-spacing: 1.5px; + margin: 3px 0 18px; +} +.catalogue-count .primary { + justify-self: end; +} +.filter-bar { + display: grid; + grid-template-columns: 2fr 1fr 1fr auto; + gap: 7px; + align-items: end; + margin: 12px 0 18px; + padding: 11px; + background: #101314; + border: 1px solid #2d3233; +} +.filter-bar label { + font-size: var(--shd-type-telemetry); + color: #72797b; + letter-spacing: 1.2px; +} +.filter-bar input, +.filter-bar select { + height: 38px; + margin-top: 6px; + padding: 0 10px; + font-size: var(--shd-type-telemetry); +} +.filter-bar button { + height: 38px; + padding: 0 14px; +} +.catalogue-grid { + max-width: none; +} +.catalogue-grid article { + position: relative; +} +.build-open { + width: 100%; + margin-top: 15px; + padding: 10px; + border: 1px solid #3a4041; + background: #101314; + color: #f6a821; + font-size: var(--shd-type-telemetry); + font-weight: bold; + letter-spacing: 1px; +} +.guide-layout { + display: grid; + grid-template-columns: 1fr 330px; + gap: 12px; +} +.guide-feature { + min-height: 360px; + padding: 35px; + position: relative; + border: 1px solid #303536; + border-top: 2px solid #f6a821; + background: linear-gradient(135deg, #151819, #0d1011); +} +.guide-code { + position: absolute; + right: 25px; + top: 22px; + color: #4f5658; + font-size: var(--shd-type-telemetry); + letter-spacing: 1.5px; +} +.guide-feature h2 { + font-size: 42px; + line-height: 0.95; + margin: 20px 0; +} +.guide-feature > p:not(.eyebrow) { + max-width: 650px; + color: #858b8d; + font: 15px/1.6 var(--shd-font-editorial); +} +.guide-meta { + display: flex; + gap: 8px; + position: absolute; + left: 35px; + bottom: 30px; +} +.guide-meta span { + display: grid; + padding: 8px 11px; + border: 1px solid #303536; + color: #60686a; + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; +} +.guide-meta b { + color: #d7d9d5; + font-size: var(--shd-type-telemetry); + margin-top: 4px; +} +.guide-index { + border: 1px solid #303536; + background: #111415; + padding: 20px; +} +.guide-index > div { + display: grid; + grid-template-columns: 30px 1fr auto; + align-items: center; + min-height: 54px; + border-top: 1px solid #292e2f; +} +.guide-index small { + color: #535b5d; + font-size: var(--shd-type-telemetry); +} +.guide-index span { + font-size: var(--shd-type-telemetry); + letter-spacing: 0.8px; +} +.guide-index b { + color: #f6a821; +} +.profile-command { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 0 24px; + border-bottom: 1px solid #303536; +} +.profile-ident { + display: flex; + align-items: center; + gap: 18px; +} +.profile-avatar { + width: 76px; + height: 76px; + display: block; + object-fit: cover; + border: 1px solid #f6a821; + border-radius: 50%; + background: #0a0c0d; + box-shadow: + 0 0 0 6px #0a0c0d, + 0 0 0 7px #f6a82155; +} +.profile-ident h1 { + font-size: 39px; + margin: 4px 0; +} +.role-badge { + display: inline-block; + padding: 4px 8px; + border: 1px solid #6e5226; + color: #f6a821; + font-size: var(--shd-type-telemetry); + letter-spacing: 1.3px; +} +.profile-metrics { + display: flex; + gap: 7px; +} +.profile-metrics div { + min-width: 77px; + height: 59px; + display: grid; + place-items: center; + padding: 7px; + background: #111415; + border: 1px solid #303536; +} +.profile-metrics strong { + font-size: 20px; +} +.profile-metrics span { + font-size: var(--shd-type-telemetry); + color: #697174; + letter-spacing: 1px; +} +.account-alert { + display: flex; + gap: 16px; + margin: 12px 0; + padding: 12px 15px; + background: #2a2112; + border: 1px solid #795c2a; + border-left: 3px solid #f6a821; + font-size: var(--shd-type-telemetry); +} +.account-alert b { + color: #f6a821; +} +.account-alert span { + color: #b8ad99; +} +.profile-dashboard { + display: grid; + grid-template-columns: minmax(0, 1fr) 340px; + gap: 12px; + margin-top: 12px; +} +.profile-feed { + display: grid; + gap: 12px; +} +.profile-module, +.account-console { + background: #111415; + border: 1px solid #303536; + padding: 16px; +} +.module-head { + display: flex; + justify-content: space-between; + align-items: center; +} +.module-head h2, +.profile-module h2, +.account-console h2 { + font-size: 17px; + margin: 4px 0 12px; +} +.module-head .secondary { + padding: 10px 12px; +} +.profile-table { + border: 1px solid #292e2f; +} +.profile-row { + display: grid; + grid-template-columns: 1.5fr 0.7fr 0.7fr 0.3fr; + align-items: center; + gap: 9px; + min-height: 47px; + padding: 7px 11px; + border-top: 1px solid #292e2f; + font-size: var(--shd-type-telemetry); +} +.profile-row.heading { + min-height: 30px; + border: 0; + background: #0d1011; + color: #636b6d; + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; +} +.profile-row > div { + display: grid; +} +.profile-row small { + font-size: var(--shd-type-telemetry); + color: #596164; +} +.profile-row > span { + text-transform: uppercase; + color: #858d8f; +} +.profile-row button { + border: 0; + background: transparent; + color: #f6a821; + font-size: var(--shd-type-telemetry); +} +.profile-empty { + text-align: center; + padding: 38px; +} +.profile-empty > span { + font-size: 31px; + color: #f6a821; +} +.profile-empty b { + display: block; + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; + margin: 10px; +} +.profile-empty p { + color: #71797b; + font: 13px var(--shd-font-editorial); +} +.profile-empty a { + color: #f6a821; + font-size: var(--shd-type-telemetry); + font-weight: bold; +} +.profile-columns { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} +.large-count { + font-size: 32px; +} +.count-label { + display: block; + font-size: var(--shd-type-telemetry); + color: #687073; + letter-spacing: 1.2px; +} +.account-console { + height: max-content; +} +.console-head { + display: flex; + justify-content: space-between; + padding-bottom: 11px; + margin-bottom: 15px; + border-bottom: 1px solid #303536; + color: #747c7e; + font-size: var(--shd-type-telemetry); + letter-spacing: 1.3px; +} +.console-head i { + width: 6px; + height: 6px; + border-radius: 50%; + background: #75c08c; + box-shadow: 0 0 6px #75c08c; +} +.console-action { + width: 100%; + margin-top: 9px; +} +.connection-card { + display: grid; + grid-template-columns: 35px 1fr auto; + align-items: center; + padding: 10px; + border: 1px solid #303536; +} +.discord-node { + color: #8993ff; + font-size: 21px; +} +.connection-card div { + display: grid; +} +.connection-card b { + font-size: var(--shd-type-telemetry); +} +.connection-card small { + font-size: var(--shd-type-telemetry); + color: #656d6f; + margin-top: 3px; +} +.connection-card a { + color: #f6a821; + font-size: var(--shd-type-telemetry); + font-weight: bold; +} +.avatar-picker { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 6px; + margin-bottom: 10px; +} +.avatar-preset-form, +.avatar-preset-form fieldset { + min-width: 0; + height: 100%; +} +.avatar-preset { + display: grid; + gap: 5px; + width: 100%; + height: 100%; + min-width: 0; + padding: 4px; + background: #0b0e0f; + border: 1px solid #303536; + color: #747c7e; +} +.avatar-preset:hover, +.avatar-preset:focus-visible { + border-color: #8a672b; + color: #f6a821; +} +.avatar-preset.selected { + border-color: #f6a821; + color: #f6a821; + box-shadow: inset 0 -2px #f6a821; +} +.avatar-preset img { + display: block; + width: 100%; + aspect-ratio: 1; + object-fit: cover; +} +.avatar-preset span { + min-width: 0; + overflow-wrap: anywhere; + font-size: var(--shd-type-telemetry); + line-height: 1.25; + text-transform: uppercase; + letter-spacing: 0.4px; +} +.avatar-upload { + min-width: 0; + margin-top: 8px; +} +.account-console .image-drop-input { + grid-template-columns: 64px minmax(0, 1fr); +} +.account-console .image-upload-actions { + grid-column: 1/-1; + display: grid; + grid-template-columns: 1fr 1fr; + width: 100%; +} +.account-console .image-upload-actions button { + min-width: 0; +} +.image-drop-input { + display: grid; + grid-template-columns: 64px 1fr auto; + align-items: center; + gap: 10px; + min-height: 82px; + padding: 8px; + border: 1px dashed #4b5152; + background: #0b0e0f; + cursor: pointer; + transition: + border-color 0.15s, + background 0.15s, + transform 0.1s; +} +.image-drop-input:hover, +.image-drop-input:focus-visible, +.image-drop-input.dragging { + border-color: var(--shd-orange); + background: #15130e; + outline: none; +} +.image-drop-input.dragging { + transform: translateY(1px); + box-shadow: inset 0 0 0 1px #f6a82155; +} +.image-file-input { + position: absolute !important; + width: 1px !important; + height: 1px !important; + overflow: hidden !important; + clip: rect(0 0 0 0) !important; + white-space: nowrap !important; +} +.image-upload-preview { + display: grid; + place-items: center; + width: 64px; + height: 64px; + border: 1px solid #343a3b; + background: #070909; + color: #62696b; + font-size: var(--shd-type-telemetry); + text-align: center; +} +.image-upload-preview img { + display: block; + width: 100%; + height: 100%; + object-fit: contain; +} +.image-drop-copy { + display: grid; + gap: 4px; + min-width: 0; +} +.image-drop-copy b { + color: #c6cbcc; + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; +} +.image-drop-copy span { + overflow: hidden; + color: #747c7e; + font-size: var(--shd-type-telemetry); + text-overflow: ellipsis; + white-space: nowrap; +} +.image-upload-actions { + display: flex; + gap: 5px; +} +.image-upload-actions button { + min-width: 92px; + padding: 8px; +} +.image-upload-error { + display: block; + margin-top: 5px; + color: var(--shd-danger); +} +@media (max-width: 700px) { + .image-drop-input { + grid-template-columns: 56px 1fr; + } + .image-upload-preview { + width: 56px; + height: 56px; + } + .image-upload-actions { + grid-column: 1/-1; + } + .image-upload-actions button { + flex: 1; + } +} +.icon-upload-field .image-drop-input { + grid-template-columns: 1fr; + gap: 6px; + text-align: center; +} +.icon-upload-field .image-upload-preview { + width: 100%; + height: 72px; +} +.icon-upload-field .image-upload-preview img { + width: 64px; + height: 64px; +} +.icon-upload-field .image-upload-actions { + display: grid; + grid-template-columns: 1fr 1fr; + width: 100%; +} +.icon-upload-field .image-upload-actions button { + min-width: 0; + padding: 7px 3px; + font-size: var(--shd-type-telemetry); +} +.game-data-render-upload { + margin: 8px 0; + padding: 8px; + border: 1px solid var(--shd-border); + background: #0d1011; +} +.game-data-render-upload legend { + padding: 0 5px; + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.8px; +} +.game-data-render-upload .image-drop-input { + grid-template-columns: 84px minmax(0, 1fr) auto; +} +.game-data-render-upload .image-upload-preview { + width: 84px; + height: 84px; +} +.game-data-render-upload .image-upload-preview img { + object-fit: cover; +} +.game-data-render-upload .image-drop-copy { + justify-self: stretch; + text-align: center; +} +.game-data-render-upload .render-remove { + display: flex; + gap: 6px; + align-items: center; + margin-top: 6px; + color: var(--shd-danger); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.6px; +} +.game-data-render-upload .render-remove input { + width: auto; + margin: 0; + accent-color: var(--shd-danger); +} +.game-data-render-upload > small { + display: block; + margin-top: 6px; + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + line-height: 1.35; +} +.dashboard-row > .registry-render-identity { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} +.registry-render-identity > div { + display: grid; + gap: 2px; + min-width: 0; +} +.registry-render { + width: 42px; + height: 42px; + flex: 0 0 auto; + object-fit: cover; + border: 1px solid var(--shd-border); + background: #090b0c; +} +.database-item-card header:has(.database-item-render) { + grid-template-columns: 64px 42px minmax(0, 1fr) auto; +} +.database-item-render { + display: block; + width: 64px; + height: 64px; + object-fit: cover; + border: 1px solid var(--shd-border); + background: #090b0c; +} +.proposal-submitted, +.data-access-banner { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 9px; + padding: 9px 11px; + border-left: 3px solid var(--shd-success); + background: #101713; + color: #a6cbb0; +} +.proposal-submitted b, +.data-access-banner b { + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; +} +.proposal-submitted span, +.data-access-banner span { + font-size: var(--shd-type-telemetry); + color: #7e9d86; +} +.data-access-banner { + border-left-color: #657174; + background: #111617; + color: #aeb7b9; +} +.data-access-banner.contributor { + border-left-color: var(--shd-orange); + background: #19150e; + color: #d8bc87; +} +.data-access-error { + margin: -3px 0 9px; +} +.data-readonly form[method='post'] button[type='submit'], +.data-readonly form[method='post'] button:not([type]) { + filter: grayscale(1); + opacity: 0.45; +} +.admin-sidebar nav a.has-pending { + grid-template-columns: minmax(0, 1fr) 18px 18px; +} +.admin-sidebar nav a.has-pending em { + grid-column: 2; + display: grid; + place-items: center; + min-width: 18px; + height: 18px; + padding: 0 5px; + border: 1px solid #a26c19; + background: #211a0e; + color: #ffc45d; + font-size: var(--shd-type-telemetry); + font-style: normal; + font-weight: 900; + text-align: center; +} +.admin-sidebar nav a.has-pending b { + grid-column: 3; + justify-self: end; +} +.admin-sidebar nav a.has-pending:hover em, +.admin-sidebar nav a.has-pending.active em { + border-color: var(--shd-orange); + background: #2b2111; + color: #fff1d0; +} +.moderation-row { + grid-template-columns: + minmax(240px, 1.5fr) minmax(140px, 0.8fr) + 150px 80px 75px; +} +.moderation-review { + width: min(1120px, 100%); + max-height: calc(100vh - 32px); + overflow: auto; +} +.moderation-metadata { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 6px; + margin: 10px 0; +} +.moderation-metadata span { + display: grid; + gap: 4px; + padding: 8px; + border: 1px solid var(--shd-border); + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); +} +.moderation-metadata b { + color: var(--shd-text); + font-size: var(--shd-type-telemetry); +} +.moderation-review details { + margin: 10px 0; + border: 1px solid var(--shd-border); + background: #0b0e0f; +} +.moderation-review summary { + padding: 9px; + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); + cursor: pointer; +} +.moderation-review pre { + max-height: 260px; + overflow: auto; + margin: 0; + padding: 10px; + border-top: 1px solid var(--shd-border); + color: #9ca6a8; + font-size: var(--shd-type-telemetry); + white-space: pre-wrap; +} +.proposal-change-preview { + margin-top: 10px; +} +.proposal-preview-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 10px; + border: 1px solid var(--shd-border); + background: #0d1011; +} +.proposal-preview-head > div { + display: grid; + gap: 3px; +} +.proposal-preview-head span { + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 1px; +} +.proposal-preview-head small { + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); +} +.proposal-preview-head > b { + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); +} +.proposal-compare-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 7px; + margin-top: 7px; +} +.proposal-editor-preview { + min-width: 0; + border: 1px solid var(--shd-border); + background: #111415; +} +.proposal-editor-preview.before { + border-top: 2px solid #596164; +} +.proposal-editor-preview.after { + border-top: 2px solid var(--shd-orange); +} +.proposal-editor-preview > header { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 32px; + padding: 6px 9px; + border-bottom: 1px solid var(--shd-border); + background: #0d1011; +} +.proposal-editor-preview > header span, +.proposal-editor-preview > header b { + font-size: var(--shd-type-telemetry); + letter-spacing: 0.9px; +} +.proposal-editor-preview > header b { + color: var(--shd-text-muted); +} +.proposal-editor-preview.after > header b { + color: var(--shd-orange); +} +.proposal-attribute-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 6px; + padding: 8px; +} +.preview-field { + min-width: 0; + display: grid; + align-content: start; + gap: 5px; +} +.preview-field.identity { + grid-column: span 4; +} +.preview-field > span { + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + letter-spacing: 0.8px; +} +.preview-field > strong, +.preview-field.range > div { + min-height: 34px; + display: flex; + align-items: center; + padding: 7px 8px; + border: 1px solid #363b3c; + background: #0d0f10; + color: var(--shd-text); + font-size: var(--shd-type-telemetry); + overflow-wrap: anywhere; +} +.preview-field.identity > strong { + min-height: 43px; + font-size: 13px; +} +.preview-field.range { + grid-column: span 2; +} +.preview-field.range > div { + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: 6px; +} +.preview-field.range strong:last-child { + text-align: right; +} +.preview-field.range i { + color: #555d5f; + font-size: var(--shd-type-telemetry); + font-style: normal; +} +.proposal-empty-state { + min-height: 180px; + display: grid; + place-content: center; + gap: 6px; + text-align: center; +} +.proposal-empty-state b { + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; +} +.proposal-empty-state span { + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); +} +.proposal-editor-preview.generic { + margin-top: 7px; +} +.proposal-field-summary { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; + padding: 8px; +} +.proposal-field-summary > div { + min-width: 0; + display: grid; + align-content: start; + gap: 5px; + padding: 8px; + border: 1px solid #303536; + background: #0d0f10; +} +.proposal-field-summary span { + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + letter-spacing: 0.8px; +} +.proposal-field-summary strong { + color: var(--shd-text); + font-size: var(--shd-type-telemetry); + overflow-wrap: anywhere; +} +.proposal-field-summary small { + max-height: 42px; + overflow: hidden; + color: #687174; + font-size: var(--shd-type-telemetry); + overflow-wrap: anywhere; +} +.decision-tabs { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 5px; + margin: 10px 0; +} +.decision-tabs button { + padding: 9px; + border: 1px solid var(--shd-border); + background: #111415; + color: var(--shd-text-muted); +} +.decision-tabs button.active { + border-color: var(--shd-orange); + color: var(--shd-orange); +} +@media (max-width: 800px) { + .moderation-row { + grid-template-columns: 1fr; + } + .moderation-metadata, + .proposal-compare-grid { + grid-template-columns: 1fr; + } + .proposal-field-summary { + grid-template-columns: 1fr 1fr; + } +} +@media (max-width: 520px) { + .proposal-attribute-grid, + .proposal-field-summary { + grid-template-columns: 1fr; + } + .preview-field.identity, + .preview-field.range { + grid-column: auto; + } +} +.account-tabs { + width: 100%; + height: auto; + margin: 0 0 16px; + display: grid; + grid-template-columns: repeat(3, 1fr); + border: 1px solid var(--shd-border); +} +.account-tabs button, +.account-subtabs button { + min-width: 0; + height: 34px; + padding: 0 5px; + border: 0; + border-left: 1px solid var(--shd-border); + border-bottom: 2px solid transparent; + background: #0d1011; + color: #6f7779; + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.8px; + cursor: pointer; + transition: var(--shd-transition); +} +.account-tabs button:first-child, +.account-subtabs button:first-child { + border-left: 0; +} +.account-tabs button:hover, +.account-tabs button:focus-visible, +.account-subtabs button:hover, +.account-subtabs button:focus-visible { + color: var(--shd-text); + background: #171b1c; +} +.account-tabs button.active, +.account-subtabs button.active { + border-bottom-color: var(--shd-orange); + background: #211a0e; + color: var(--shd-orange); +} +.account-tabs button:active, +.account-subtabs button:active { + transform: translateY(1px); + filter: brightness(0.85); +} +.account-tabs button:focus-visible, +.account-subtabs button:focus-visible { + position: relative; + z-index: 1; + outline: 2px solid var(--shd-orange); + outline-offset: 2px; +} +.account-panel { + min-height: 290px; +} +.account-subtabs { + display: grid; + grid-template-columns: 1fr 1fr; + margin: 0 0 10px; + border: 1px solid var(--shd-border); +} +.account-subtabs button { + height: 31px; +} +.access-subtabs { + display: grid; + grid-template-columns: repeat(4, 1fr); + margin: -2px 0 17px; + border: 1px solid var(--shd-border); + background: #0b0e0f; +} +.access-subtabs button { + min-width: 0; + height: 42px; + display: grid; + place-content: center; + gap: 3px; + padding: 4px; + border: 0; + border-left: 1px solid var(--shd-border); + border-bottom: 2px solid transparent; + background: transparent; + color: var(--shd-text-dim); + cursor: pointer; + transition: var(--shd-transition); +} +.access-subtabs button:first-child { + border-left: 0; +} +.access-subtabs button span { + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.7px; +} +.access-subtabs button b { + font-size: var(--shd-type-telemetry); +} +.access-subtabs button:hover, +.access-subtabs button:focus-visible { + background: #171b1c; + color: var(--shd-text); +} +.access-subtabs button.active { + border-bottom-color: var(--shd-orange); + background: #211a0e; + color: var(--shd-orange); +} +.access-subtabs button:active { + transform: translateY(1px); + filter: brightness(0.85); +} +.access-subtabs button:focus-visible { + position: relative; + z-index: 1; + outline: 2px solid var(--shd-orange); + outline-offset: 2px; +} +.account-retention { + color: var(--shd-text-muted); + font: 12px/1.55 var(--shd-font-editorial); +} +.danger-eyebrow { + color: var(--shd-danger); +} +.account-alert.error-alert { + background: #261513; + border-color: #75443b; + border-left-color: #df8069; +} +.account-alert.error-alert b { + color: #df8069; +} +.account-alert.error-alert span { + color: #d2aaa1; +} +.danger-zone { + margin-top: 12px; + border: 1px solid #563734; + background: #181112; +} +.danger-zone summary { + padding: 10px; + color: #df8069; + cursor: pointer; + font-size: var(--shd-type-telemetry); + font-weight: bold; + letter-spacing: 1px; +} +.danger-zone > p, +.danger-zone > form { + margin: 0; + padding: 0 10px 10px; +} +.danger-zone > p { + color: #9f8580; + font-size: var(--shd-type-telemetry); + line-height: 1.5; +} +.danger-zone form { + padding-bottom: 10px; +} +.auth-page { + display: block; + padding: 55px clamp(20px, 5vw, 80px); +} +.auth-frame { + width: min(980px, 100%); + min-height: 520px; + margin: auto; + display: grid; + grid-template-columns: 1.15fr 0.85fr; + border: 1px solid #34393a; + background: #101314; + clip-path: polygon(0 0, 96% 0, 100% 7%, 100% 100%, 4% 100%, 0 93%); +} +.auth-intel { + width: auto !important; + padding: 65px !important; + border: 0 !important; + border-right: 1px solid #303536 !important; + background: + radial-gradient(circle at 15% 80%, #392b15 0, transparent 33%), #0e1112 !important; +} +.auth-intel h1 { + font-size: 66px !important; + line-height: 0.85; + letter-spacing: -3px; + margin: 24px 0; +} +.auth-intel h1 em { + font-style: normal; + color: #f6a821; +} +.auth-intel > p:not(.eyebrow) { + max-width: 470px; + color: #909698; + font: 15px/1.6 var(--shd-font-editorial); +} +.auth-status { + display: flex; + justify-content: space-between; + margin-top: 55px; + padding-top: 14px; + border-top: 1px solid #303536; + color: #626a6c; + font-size: var(--shd-type-telemetry); + letter-spacing: 1.2px; +} +.auth-status i { + display: inline-block; + width: 5px; + height: 5px; + border-radius: 50%; + background: #75c08c; + box-shadow: 0 0 6px #75c08c; + margin-right: 5px; +} +.auth-console { + width: auto !important; + padding: 34px !important; + border: 0 !important; + background: #141718 !important; +} +.auth-console .console-head b { + color: #f6a821; +} +.auth-console .discord { + min-height: 48px; + margin-top: 18px; + padding: 10px 18px; + display: flex; + align-items: center; + justify-content: center; + gap: 14px; + background: #5865f2; + color: #fff; + border: 0; + border-radius: 4px; + font-size: 15px; + font-weight: 600; + letter-spacing: 0; + text-transform: none; +} +.auth-console .discord img { + display: block; + width: 27px; + height: 21px; + object-fit: contain; + flex: 0 0 auto; +} +.auth-console .discord:hover { + background: #4752c4; + filter: none; +} +.auth-console .discord:active { + background: #3c45a5; + filter: none; +} +.auth-console .discord:focus-visible { + outline-color: #e0e3ff; +} +.auth-console small { + margin-top: 18px; +} +.signin-provider-rail { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 7px; +} +.signin-provider { + position: relative; + min-width: 0; + min-height: 76px; + margin: 0; + padding: 8px 4px 7px; + display: grid; + grid-template-rows: 34px auto; + place-items: center; + gap: 5px; + border: 1px solid #424849; + border-radius: 0; + background: #101314; + color: #dfe1e2; + font-family: var(--shd-font-sans); + font-size: var(--shd-type-telemetry); + font-style: normal; + font-weight: 800; + letter-spacing: 0.3px; + text-transform: none; + cursor: pointer; + transition: var(--shd-transition); +} +.signin-provider-icon { + width: 34px; + height: 34px; + display: grid; + place-items: center; +} +.signin-provider-icon img { + display: block; + width: 21px; + height: 21px; + object-fit: contain; +} +.signin-provider em { + position: absolute; + right: 3px; + top: 3px; + color: #737a7c; + font-size: var(--shd-type-telemetry); + font-style: normal; + font-weight: 900; + letter-spacing: 0.5px; + text-transform: uppercase; +} +.signin-provider:hover:not(:disabled) { + transform: translateY(-1px); + filter: brightness(1.12); +} +.signin-provider:active:not(:disabled) { + transform: translateY(2px) scale(0.97); +} +.signin-provider:focus-visible { + outline: 2px solid var(--shd-orange); + outline-offset: 2px; +} +.signin-provider:disabled { + cursor: not-allowed; + opacity: 0.46; + filter: saturate(0.55); +} +.discord-provider { + border-color: #5865f2; + background: #5865f2; + color: #fff; +} +.discord-provider:hover { + background: #4752c4; + filter: none !important; +} +.discord-provider .signin-provider-icon img { + width: 25px; + height: 19px; +} +.google-signin-provider { + border-color: #8e918f; + background: #131314; + color: #e3e3e3; +} +.google-signin-provider .signin-provider-icon { + background: #fff; +} +.apple-signin-provider { + border-color: #73777a; + background: #000; + color: #fff; +} +.apple-signin-provider .signin-provider-icon img { + width: 22px; + height: 22px; +} +.steam-signin-provider { + border-color: #66c0f4; + background: #171d25; + color: #fff; +} +.steam-signin-provider .signin-provider-icon { + color: #fff; +} +.steam-signin-provider .signin-provider-icon img { + width: 25px; + height: 25px; +} +.auth-mode-note { + margin: -3px 0 14px; + color: #7b8385; + font-size: var(--shd-type-telemetry); + line-height: 1.5; +} +.auth-mode-switch { + display: grid; + grid-template-columns: 1fr 1fr; + margin: 12px 0 4px; + border: 1px solid #383d3e; +} +.auth-mode-switch button { + height: 34px; + border: 0; + border-left: 1px solid #383d3e; + background: #0d1011; + color: #747c7e; + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 1px; + cursor: pointer; +} +.auth-mode-switch button:first-child { + border-left: 0; +} +.auth-mode-switch button.active { + background: #2b2111; + color: var(--shd-orange); + box-shadow: inset 0 -2px var(--shd-orange); +} +.auth-mode-switch button:focus-visible { + outline: 2px solid var(--shd-orange); + outline-offset: 2px; +} +.auth-page .auth-console form .full + .identity-note { + margin-top: -14px; +} +.passkey-signin { + position: relative; + min-height: 48px; + margin-top: 18px; +} +.passkey-signin[aria-busy='true'] { + padding-right: 42px; +} +.passkey-signin[aria-busy='true']:after { + content: ''; + position: absolute; + right: 16px; + width: 12px; + height: 12px; + border: 2px solid #111; + border-right-color: transparent; + border-radius: 50%; + animation: interaction-spin 0.65s linear infinite; +} +.auth-note { + margin: 0; + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); +} +.password-fallback { + margin: 18px 0; + border: 1px solid var(--shd-border); + background: #101314; +} +.password-fallback summary { + padding: 12px; + color: #8e9698; + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 1px; + cursor: pointer; +} +.password-fallback summary:hover { + color: var(--shd-orange); +} +.password-fallback form { + padding: 0 12px 12px; +} +.password-fallback .full { + margin-bottom: 0; +} +.builder-page { + width: 100%; + min-width: 0; + padding-top: 28px; +} +.builder-command { + margin-bottom: 14px; + border-bottom: 1px solid #303536; +} +.builder-command .page-title { + margin-bottom: 12px; +} +.builder-status { + display: flex; + gap: 24px; + padding: 9px 2px; + color: #61696b; + font-size: var(--shd-type-telemetry); + letter-spacing: 1.2px; +} +.builder-status i { + display: inline-block; + width: 5px; + height: 5px; + border-radius: 50%; + background: #75c08c; + box-shadow: 0 0 6px #75c08c; + margin-right: 5px; +} +.builder-page .slot-card { + position: relative; + overflow: hidden; +} +.builder-page .slot-card:after { + content: ''; + position: absolute; + right: -24px; + bottom: -24px; + width: 52px; + height: 52px; + border: 1px solid #f6a82122; + transform: rotate(45deg); +} +.builder-page .summary { + border-top: 2px solid #f6a821; +} +@media (max-width: 900px) { + .filter-bar { + grid-template-columns: 1fr 1fr; + } + .catalogue-hero { + align-items: flex-start; + } + .guide-layout, + .profile-dashboard, + .auth-frame { + grid-template-columns: 1fr; + } + .profile-command { + align-items: flex-start; + } + .profile-metrics { + display: grid; + grid-template-columns: 1fr 1fr; + } + .account-console { + order: -1; + } + .auth-intel { + border-right: 0 !important; + border-bottom: 1px solid #303536 !important; + padding: 42px !important; + } + .auth-intel h1 { + font-size: 52px !important; + } +} +@media (max-width: 560px) { + .catalogue-hero, + .profile-command { + flex-direction: column; + align-items: flex-start; + } + .catalogue-count { + text-align: left; + margin-top: 20px; + } + .catalogue-count .primary { + justify-self: start; + } + .filter-bar { + grid-template-columns: 1fr; + } + .guide-meta { + position: static; + margin-top: 25px; + flex-wrap: wrap; + } + .profile-metrics { + width: 100%; + margin-top: 20px; + } + .profile-columns { + grid-template-columns: 1fr; + } + .auth-page { + padding: 25px 14px; + } + .auth-intel, + .auth-console { + padding: 28px !important; + } +} +@media (max-width: 420px) { + .signin-provider-rail { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} -.discord-connection{display:grid;grid-template-columns:1fr;gap:9px;padding:10px}.connection-copy{display:flex!important;align-items:center;gap:10px}.connection-copy>img{width:28px;height:21px;padding:4px;background:#5865f2;border-radius:3px}.discord-link-button{min-height:42px;padding:8px 14px;display:flex;align-items:center;justify-content:center;gap:12px;background:#5865f2;color:#fff!important;border-radius:4px;font-size:14px!important;font-weight:600!important;letter-spacing:0;text-transform:none}.discord-link-button img{display:block;width:25px;height:19px;object-fit:contain}.discord-link-button:hover{background:#4752c4;filter:none}.discord-link-button:active{background:#3c45a5;filter:none}.discord-link-button:focus-visible{outline:2px solid #e0e3ff;outline-offset:3px}.discord-link-button[aria-busy="true"]::after{content:"";width:11px;height:11px;border:2px solid #fff;border-right-color:transparent;border-radius:50%;animation:interaction-spin .65s linear infinite} -.provider-stack{display:grid;gap:7px}.provider-placeholder{min-height:52px;display:grid;grid-template-columns:34px 1fr auto;align-items:center;gap:9px;padding:8px 10px;border:1px solid var(--shd-border-soft);background:#0d1011;color:#6d7476;opacity:.72}.provider-placeholder>div{display:grid;gap:3px}.provider-placeholder b{font-size:var(--shd-type-telemetry);color:#8a9092}.provider-placeholder small{font-size:var(--shd-type-telemetry);letter-spacing:.7px}.provider-placeholder em{font-style:normal;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1px;color:#626a6c}.provider-monogram{width:28px;height:28px;display:grid;place-items:center;border:1px solid #3a4041;background:#151819;font-size:var(--shd-type-telemetry);font-weight:900}.google-provider{color:#8ab4f8}.apple-provider{color:#d7d9d5}.steam-provider{color:#70b9d6} -.provider-connection-card{position:relative;grid-template-columns:1fr auto!important;min-height:64px;overflow:hidden}.connection-state.provider-unlink-trigger{height:auto;padding:5px 7px;background:transparent;font-family:inherit;cursor:pointer;transition:var(--shd-transition)}.connection-state.provider-unlink-trigger span:last-child{display:none}.connection-state.provider-unlink-trigger:hover:not(:disabled),.connection-state.provider-unlink-trigger:focus-visible{border-color:var(--shd-danger);background:#281716;color:var(--shd-danger)}.connection-state.provider-unlink-trigger:hover:not(:disabled) span:first-child,.connection-state.provider-unlink-trigger:focus-visible span:first-child{display:none}.connection-state.provider-unlink-trigger:hover:not(:disabled) span:last-child,.connection-state.provider-unlink-trigger:focus-visible span:last-child{display:inline}.connection-state.provider-unlink-trigger:focus-visible{outline:2px solid var(--shd-danger);outline-offset:2px}.connection-state.provider-unlink-trigger:disabled{cursor:not-allowed;opacity:.55}.provider-unlink-confirm{position:absolute;z-index:2;inset:0;display:grid!important;grid-template-columns:1fr auto;align-items:center;gap:9px;padding:9px 10px;border:1px solid #9a5043;background:#211414}.provider-unlink-confirm>div:first-child{display:grid!important;gap:3px}.provider-unlink-confirm b{color:var(--shd-danger);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.provider-unlink-confirm p{margin:0;color:#d6aaa1;font-size:var(--shd-type-telemetry);line-height:1.35}.provider-unlink-confirm>div:last-child{display:grid!important;grid-template-columns:74px 104px;gap:6px}.provider-unlink-confirm form,.provider-unlink-confirm fieldset{height:100%}.provider-unlink-confirm button{width:100%;height:32px;padding:0 7px;border-radius:0;appearance:none;font-size:var(--shd-type-telemetry)}.provider-unlink-confirm .secondary{border:1px solid #565d5f;background:#101314;color:#d7d9d5}.provider-unlink-confirm .secondary:hover{border-color:#8a672b;color:var(--shd-orange)}.provider-unlink-confirm .primary{border:0;background:var(--shd-orange);color:#111}.provider-unlink-confirm .primary:hover{background:var(--shd-orange-hover)}.provider-card-message{grid-column:1/-1;margin:0} -.linked-connection{grid-template-columns:1fr auto}.connection-state{padding:5px 7px;border:1px solid #3f6a4c;color:#75c08c;font-size:var(--shd-type-telemetry);font-weight:bold;letter-spacing:1px}.unlink-console{margin-top:8px}.connection-warning{margin:8px 0;color:#c29a60;font-size:var(--shd-type-telemetry);line-height:1.4} -.isac-identity{margin:10px 0 14px}.isac-identity>.label{margin-bottom:6px}.identity-empty{padding:12px!important;border:1px dashed #343a3b} -.isac-avatar-review{margin:10px 0 14px}.isac-avatar-review>.label{margin-bottom:6px}.avatar-review-card{position:relative;display:grid;grid-template-columns:58px 1fr auto;align-items:center;gap:10px;padding:8px;border:1px solid #303536;background:#0d1011;overflow:hidden}.avatar-review-card img{width:58px;height:58px;object-fit:cover;border:1px solid #5a4829}.avatar-review-card>div{display:grid;gap:4px}.avatar-review-card b{font-size:var(--shd-type-telemetry)}.avatar-review-card small{color:#747c7e;font-size:var(--shd-type-telemetry);letter-spacing:.4px}.avatar-review-card>span{color:#75c08c;font-size:var(--shd-type-telemetry);letter-spacing:1px}.avatar-review-card>span.custom-avatar{color:#f6a821}.avatar-review-card>.moderation-action{height:34px}.avatar-review-note{padding:9px 0 0}.avatar-review-card>.avatar-remove-confirm{display:grid;gap:8px} -.moderation-action,.isac-control-link{position:relative;min-height:40px;display:inline-flex;align-items:center;justify-content:center;gap:9px;border-radius:0;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1.15px;text-transform:uppercase;cursor:pointer;transition:border-color .14s ease,background-color .14s ease,color .14s ease,transform .08s ease,filter .14s ease}.moderation-action.secondary,.isac-control-link{border:1px solid #424849;background:#101314;color:#d7d9d5}.moderation-action.secondary:hover,.isac-control-link:hover{border-color:#8a672b;background:#181711;color:#f6a821}.moderation-action.danger{border:1px solid #70423b;background:#211414;color:#ee8b78}.moderation-action.danger:hover{border-color:#d06d58;background:#321a17;color:#ffad9d}.moderation-action:focus-visible,.isac-control-link:focus-visible{outline:2px solid #f6a821;outline-offset:3px}.moderation-action:not(:disabled):active,.isac-control-link:active{transform:translateY(2px) scale(.985);filter:brightness(.86)}.moderation-action:disabled{border-color:#343839;background:#121415;color:#5f6668;cursor:not-allowed;opacity:.65}.moderation-panel fieldset[aria-busy=true] .moderation-action{cursor:wait;padding-right:34px}.moderation-panel fieldset[aria-busy=true] .moderation-action:after{content:"";position:absolute;right:12px;width:10px;height:10px;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;animation:interaction-spin .65s linear infinite} -@keyframes interaction-spin{to{transform:rotate(360deg)}}@media(prefers-reduced-motion:reduce){.moderation-action,.isac-control-link{transition:none}.moderation-action:not(:disabled):active,.isac-control-link:active{transform:none}.moderation-panel fieldset[aria-busy=true] .moderation-action:after,.discord-link-button[aria-busy="true"]:after{animation-duration:1.4s}} -.identity-note{display:block;margin-top:5px;color:#687073;font-size:var(--shd-type-telemetry);line-height:1.4} -.passkey-intro{margin:-6px 0 10px}.passkey-list{display:grid;border:1px solid var(--shd-border)}.passkey-card{min-height:48px;display:grid;grid-template-columns:1fr auto;align-items:center;gap:8px;padding:7px 9px;border-top:1px solid var(--shd-border-soft);background:#0d1011}.passkey-card:first-child{border-top:0}.passkey-card>div{display:grid;gap:4px}.passkey-card b,.verified-email b{font-size:var(--shd-type-telemetry)}.passkey-card small,.verified-email small{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);letter-spacing:.7px}.passkey-remove{height:30px;padding:0 8px;border:1px solid #563734;background:#181112;color:var(--shd-danger);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px;cursor:pointer}.passkey-remove:hover{border-color:var(--shd-danger);background:#281716}.account-method-empty{margin:0;padding:20px;text-align:center;border:1px dashed var(--shd-border);color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);letter-spacing:1px}.verified-email{display:grid;grid-template-columns:1fr auto;align-items:center;padding:9px;border:1px solid #3f6a4c;background:#101713}.verified-email>div{display:grid;gap:4px}.verified-email>span{color:var(--shd-success);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1px} -.method-removal{margin-top:14px;border:1px solid #563734;background:#181112}.method-removal summary{padding:10px;color:var(--shd-danger);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1px;cursor:pointer}.method-removal>p,.method-removal>form{margin:0;padding:0 10px 10px}.method-removal>p{color:#9f8580;font-size:var(--shd-type-telemetry);line-height:1.5} +.discord-connection { + display: grid; + grid-template-columns: 1fr; + gap: 9px; + padding: 10px; +} +.connection-copy { + display: flex !important; + align-items: center; + gap: 10px; +} +.connection-copy > img { + width: 28px; + height: 21px; + padding: 4px; + background: #5865f2; + border-radius: 3px; +} +.discord-link-button { + min-height: 42px; + padding: 8px 14px; + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + background: #5865f2; + color: #fff !important; + border-radius: 4px; + font-size: 14px !important; + font-weight: 600 !important; + letter-spacing: 0; + text-transform: none; +} +.discord-link-button img { + display: block; + width: 25px; + height: 19px; + object-fit: contain; +} +.discord-link-button:hover { + background: #4752c4; + filter: none; +} +.discord-link-button:active { + background: #3c45a5; + filter: none; +} +.discord-link-button:focus-visible { + outline: 2px solid #e0e3ff; + outline-offset: 3px; +} +.discord-link-button[aria-busy='true']::after { + content: ''; + width: 11px; + height: 11px; + border: 2px solid #fff; + border-right-color: transparent; + border-radius: 50%; + animation: interaction-spin 0.65s linear infinite; +} +.provider-stack { + display: grid; + gap: 7px; +} +.provider-placeholder { + min-height: 52px; + display: grid; + grid-template-columns: 34px 1fr auto; + align-items: center; + gap: 9px; + padding: 8px 10px; + border: 1px solid var(--shd-border-soft); + background: #0d1011; + color: #6d7476; + opacity: 0.72; +} +.provider-placeholder > div { + display: grid; + gap: 3px; +} +.provider-placeholder b { + font-size: var(--shd-type-telemetry); + color: #8a9092; +} +.provider-placeholder small { + font-size: var(--shd-type-telemetry); + letter-spacing: 0.7px; +} +.provider-placeholder em { + font-style: normal; + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 1px; + color: #626a6c; +} +.provider-monogram { + width: 28px; + height: 28px; + display: grid; + place-items: center; + border: 1px solid #3a4041; + background: #151819; + font-size: var(--shd-type-telemetry); + font-weight: 900; +} +.google-provider { + color: #8ab4f8; +} +.apple-provider { + color: #d7d9d5; +} +.steam-provider { + color: #70b9d6; +} +.provider-connection-card { + position: relative; + grid-template-columns: 1fr auto !important; + min-height: 64px; + overflow: hidden; +} +.connection-state.provider-unlink-trigger { + height: auto; + padding: 5px 7px; + background: transparent; + font-family: inherit; + cursor: pointer; + transition: var(--shd-transition); +} +.connection-state.provider-unlink-trigger span:last-child { + display: none; +} +.connection-state.provider-unlink-trigger:hover:not(:disabled), +.connection-state.provider-unlink-trigger:focus-visible { + border-color: var(--shd-danger); + background: #281716; + color: var(--shd-danger); +} +.connection-state.provider-unlink-trigger:hover:not(:disabled) span:first-child, +.connection-state.provider-unlink-trigger:focus-visible span:first-child { + display: none; +} +.connection-state.provider-unlink-trigger:hover:not(:disabled) span:last-child, +.connection-state.provider-unlink-trigger:focus-visible span:last-child { + display: inline; +} +.connection-state.provider-unlink-trigger:focus-visible { + outline: 2px solid var(--shd-danger); + outline-offset: 2px; +} +.connection-state.provider-unlink-trigger:disabled { + cursor: not-allowed; + opacity: 0.55; +} +.provider-unlink-confirm { + position: absolute; + z-index: 2; + inset: 0; + display: grid !important; + grid-template-columns: 1fr auto; + align-items: center; + gap: 9px; + padding: 9px 10px; + border: 1px solid #9a5043; + background: #211414; +} +.provider-unlink-confirm > div:first-child { + display: grid !important; + gap: 3px; +} +.provider-unlink-confirm b { + color: var(--shd-danger); + font-size: var(--shd-type-telemetry); + letter-spacing: 0.8px; +} +.provider-unlink-confirm p { + margin: 0; + color: #d6aaa1; + font-size: var(--shd-type-telemetry); + line-height: 1.35; +} +.provider-unlink-confirm > div:last-child { + display: grid !important; + grid-template-columns: 74px 104px; + gap: 6px; +} +.provider-unlink-confirm form, +.provider-unlink-confirm fieldset { + height: 100%; +} +.provider-unlink-confirm button { + width: 100%; + height: 32px; + padding: 0 7px; + border-radius: 0; + appearance: none; + font-size: var(--shd-type-telemetry); +} +.provider-unlink-confirm .secondary { + border: 1px solid #565d5f; + background: #101314; + color: #d7d9d5; +} +.provider-unlink-confirm .secondary:hover { + border-color: #8a672b; + color: var(--shd-orange); +} +.provider-unlink-confirm .primary { + border: 0; + background: var(--shd-orange); + color: #111; +} +.provider-unlink-confirm .primary:hover { + background: var(--shd-orange-hover); +} +.provider-card-message { + grid-column: 1/-1; + margin: 0; +} +.linked-connection { + grid-template-columns: 1fr auto; +} +.connection-state { + padding: 5px 7px; + border: 1px solid #3f6a4c; + color: #75c08c; + font-size: var(--shd-type-telemetry); + font-weight: bold; + letter-spacing: 1px; +} +.unlink-console { + margin-top: 8px; +} +.connection-warning { + margin: 8px 0; + color: #c29a60; + font-size: var(--shd-type-telemetry); + line-height: 1.4; +} +.isac-identity { + margin: 10px 0 14px; +} +.isac-identity > .label { + margin-bottom: 6px; +} +.identity-empty { + padding: 12px !important; + border: 1px dashed #343a3b; +} +.isac-avatar-review { + margin: 10px 0 14px; +} +.isac-avatar-review > .label { + margin-bottom: 6px; +} +.avatar-review-card { + position: relative; + display: grid; + grid-template-columns: 58px 1fr auto; + align-items: center; + gap: 10px; + padding: 8px; + border: 1px solid #303536; + background: #0d1011; + overflow: hidden; +} +.avatar-review-card img { + width: 58px; + height: 58px; + object-fit: cover; + border: 1px solid #5a4829; +} +.avatar-review-card > div { + display: grid; + gap: 4px; +} +.avatar-review-card b { + font-size: var(--shd-type-telemetry); +} +.avatar-review-card small { + color: #747c7e; + font-size: var(--shd-type-telemetry); + letter-spacing: 0.4px; +} +.avatar-review-card > span { + color: #75c08c; + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; +} +.avatar-review-card > span.custom-avatar { + color: #f6a821; +} +.avatar-review-card > .moderation-action { + height: 34px; +} +.avatar-review-note { + padding: 9px 0 0; +} +.avatar-review-card > .avatar-remove-confirm { + display: grid; + gap: 8px; +} +.moderation-action, +.isac-control-link { + position: relative; + min-height: 40px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 9px; + border-radius: 0; + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 1.15px; + text-transform: uppercase; + cursor: pointer; + transition: + border-color 0.14s ease, + background-color 0.14s ease, + color 0.14s ease, + transform 0.08s ease, + filter 0.14s ease; +} +.moderation-action.secondary, +.isac-control-link { + border: 1px solid #424849; + background: #101314; + color: #d7d9d5; +} +.moderation-action.secondary:hover, +.isac-control-link:hover { + border-color: #8a672b; + background: #181711; + color: #f6a821; +} +.moderation-action.danger { + border: 1px solid #70423b; + background: #211414; + color: #ee8b78; +} +.moderation-action.danger:hover { + border-color: #d06d58; + background: #321a17; + color: #ffad9d; +} +.moderation-action:focus-visible, +.isac-control-link:focus-visible { + outline: 2px solid #f6a821; + outline-offset: 3px; +} +.moderation-action:not(:disabled):active, +.isac-control-link:active { + transform: translateY(2px) scale(0.985); + filter: brightness(0.86); +} +.moderation-action:disabled { + border-color: #343839; + background: #121415; + color: #5f6668; + cursor: not-allowed; + opacity: 0.65; +} +.moderation-panel fieldset[aria-busy='true'] .moderation-action { + cursor: wait; + padding-right: 34px; +} +.moderation-panel fieldset[aria-busy='true'] .moderation-action:after { + content: ''; + position: absolute; + right: 12px; + width: 10px; + height: 10px; + border: 2px solid currentColor; + border-right-color: transparent; + border-radius: 50%; + animation: interaction-spin 0.65s linear infinite; +} +@keyframes interaction-spin { + to { + transform: rotate(360deg); + } +} +@media (prefers-reduced-motion: reduce) { + .moderation-action, + .isac-control-link { + transition: none; + } + .moderation-action:not(:disabled):active, + .isac-control-link:active { + transform: none; + } + .moderation-panel fieldset[aria-busy='true'] .moderation-action:after, + .discord-link-button[aria-busy='true']:after { + animation-duration: 1.4s; + } +} +.identity-note { + display: block; + margin-top: 5px; + color: #687073; + font-size: var(--shd-type-telemetry); + line-height: 1.4; +} +.passkey-intro { + margin: -6px 0 10px; +} +.passkey-list { + display: grid; + border: 1px solid var(--shd-border); +} +.passkey-card { + min-height: 48px; + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + gap: 8px; + padding: 7px 9px; + border-top: 1px solid var(--shd-border-soft); + background: #0d1011; +} +.passkey-card:first-child { + border-top: 0; +} +.passkey-card > div { + display: grid; + gap: 4px; +} +.passkey-card b, +.verified-email b { + font-size: var(--shd-type-telemetry); +} +.passkey-card small, +.verified-email small { + color: var(--shd-text-dim); + font-size: var(--shd-type-telemetry); + letter-spacing: 0.7px; +} +.passkey-remove { + height: 30px; + padding: 0 8px; + border: 1px solid #563734; + background: #181112; + color: var(--shd-danger); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.7px; + cursor: pointer; +} +.passkey-remove:hover { + border-color: var(--shd-danger); + background: #281716; +} +.account-method-empty { + margin: 0; + padding: 20px; + text-align: center; + border: 1px dashed var(--shd-border); + color: var(--shd-text-dim); + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; +} +.verified-email { + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + padding: 9px; + border: 1px solid #3f6a4c; + background: #101713; +} +.verified-email > div { + display: grid; + gap: 4px; +} +.verified-email > span { + color: var(--shd-success); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 1px; +} +.method-removal { + margin-top: 14px; + border: 1px solid #563734; + background: #181112; +} +.method-removal summary { + padding: 10px; + color: var(--shd-danger); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 1px; + cursor: pointer; +} +.method-removal > p, +.method-removal > form { + margin: 0; + padding: 0 10px 10px; +} +.method-removal > p { + color: #9f8580; + font-size: var(--shd-type-telemetry); + line-height: 1.5; +} -.brand-name{display:block;max-width:420px;margin:10px 0}.effect-editor{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.effect-band{display:grid;grid-template-columns:58px minmax(0,1fr) 100px;align-items:end;gap:8px;padding:9px;background:#101314;border:1px solid #343a3b}.effect-band>strong{align-self:stretch;display:grid;place-content:center;text-align:center;color:#f6a821;font-size:23px;border-right:1px solid #343a3b}.effect-band>strong small{display:block;color:#707779;font-size:var(--shd-type-telemetry);letter-spacing:1px}.effect-band .field{margin:0}.split-actions{justify-content:space-between}.split-actions>div{display:flex;gap:8px}.brand-set-row{grid-template-columns:1.1fr 1.2fr 1.2fr 1.2fr .6fr .3fr}.empty-dashboard{min-height:120px;display:grid;place-content:center;text-align:center;gap:6px;color:#626a6c}.empty-dashboard strong{font-size:var(--shd-type-telemetry);letter-spacing:1.2px}.empty-dashboard span{font-size:var(--shd-type-telemetry)} -.gear-identity{display:grid;grid-template-columns:2fr 1fr .65fr .65fr;gap:8px}.gear-ranges{display:grid;grid-template-columns:1fr 1fr;gap:8px}.range-band{grid-template-columns:58px 1fr 1fr}.effect-band.unavailable{opacity:.4}.selection-head{display:flex;justify-content:space-between;align-items:end;margin:14px 0 6px}.selection-head>div{display:grid}.selection-head span{font-size:var(--shd-type-telemetry);font-weight:bold;letter-spacing:1.2px}.selection-head small{font-size:var(--shd-type-telemetry);color:#697174;margin-top:3px}.selection-head b{color:#f6a821;font-size:var(--shd-type-telemetry);letter-spacing:1px}.selection-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));border:1px solid #303536}.selection-grid label{min-height:38px;display:grid;grid-template-columns:20px 1fr auto;align-items:center;gap:5px;padding:6px 8px;border:1px solid transparent;border-right-color:#282d2e;border-bottom-color:#282d2e;cursor:pointer;font-size:var(--shd-type-telemetry)}.selection-grid label:hover{background:#171b1c;border-color:#4b4f50}.selection-grid input{width:auto;margin:0;accent-color:#f6a821}.selection-grid small{font-size:var(--shd-type-telemetry);color:#646c6e;letter-spacing:.8px}.selection-grid input:checked~small{color:#75c08c}.gear-row{min-width:1050px;grid-template-columns:1.2fr .8fr .8fr 1.35fr .8fr .6fr .3fr}.gear-row>span small{font-size:var(--shd-type-telemetry);color:#858b8d} -.unit-field{min-width:0;margin:8px 0;padding:0;border:0}.unit-field legend{font-size:var(--shd-type-telemetry);font-weight:bold;letter-spacing:1px;color:#858b8d}.unit-picker{display:grid;grid-template-columns:repeat(5,1fr);height:32px;margin-top:5px;border:1px solid #424849}.unit-picker label{display:grid;place-items:center;border-left:1px solid #303536;cursor:pointer}.unit-picker label:first-child{border-left:0}.unit-picker input{position:absolute;opacity:0;pointer-events:none}.unit-picker span{width:100%;height:100%;display:grid;place-items:center;color:#777f81;font-size:var(--shd-type-telemetry);font-weight:bold}.unit-picker label:hover span{background:#191d1e;color:#ecebe5}.unit-picker input:checked+span{background:#2b2111;color:#f6a821;box-shadow:inset 0 -2px #f6a821}.unit-picker input:focus-visible+span{outline:2px solid #f6a821;outline-offset:2px} -.template-field{display:grid;margin-top:10px}.template-field small{margin-top:5px;color:#697174;font-size:var(--shd-type-telemetry)}.tier-preview{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin:8px 0 12px}.tier-preview>div{padding:9px 11px;border:1px solid #343a3b;background:#101314}.tier-preview span,.effect-heading{color:#697174;font-size:var(--shd-type-telemetry);letter-spacing:1.1px}.tier-preview p{min-height:30px;margin:5px 0 0;color:#d9d9d3;font-size:var(--shd-type-telemetry);line-height:1.45}.shared-trigger-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));max-height:170px}.shared-effects{border:1px solid #303536}.shared-effect{display:grid;grid-template-columns:minmax(180px,1fr) 120px 120px 30px;gap:6px;align-items:center;padding:6px;border-top:1px solid #292f30}.shared-effect:first-child{border-top:0}.shared-effect select,.shared-effect input{min-width:0;margin:0;padding:7px;font-size:var(--shd-type-telemetry)}.shared-effect button{height:30px;border:1px solid #563734;background:#1d1212;color:#da746c}.effect-heading{min-height:28px;background:#0d1011}.effect-heading span:last-child{display:none} -.talent-row{grid-template-columns:minmax(150px,.8fr) minmax(105px,.45fr) minmax(260px,1.35fr) minmax(260px,1.35fr) 80px 45px;min-width:980px}.talent-row:not(.table-heading){align-items:start;padding-top:10px;padding-bottom:10px}.talent-description{margin:0;color:#b7bcba;font-size:var(--shd-type-telemetry);line-height:1.45;white-space:normal}.talent-description.unconfigured{color:#df8069;font-size:var(--shd-type-telemetry);letter-spacing:.8px}.talent-row .state-dot{margin-top:2px}.talent-row>button{margin-top:-3px} -.talent-mechanics-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin-top:8px;padding:8px;border:1px solid var(--shd-border);background:#0d1011}.talent-mechanics-grid .field{margin:0}.talent-template-note{margin:6px 0 0;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:.55px}.talent-cooldown{display:block!important;margin-top:3px;color:var(--shd-orange)!important;font-style:normal;font-size:var(--shd-type-telemetry)!important;letter-spacing:.55px}.summary-talent .talent-cooldown{margin-top:5px}@media(max-width:700px){.talent-mechanics-grid{grid-template-columns:1fr}} -.talent-kpis{grid-template-columns:repeat(6,minmax(105px,1fr))} -@media(max-width:1200px){.talent-kpis{grid-template-columns:repeat(3,minmax(105px,1fr))}}@media(max-width:650px){.talent-kpis{grid-template-columns:repeat(2,minmax(105px,1fr))}} -.section-label{margin-top:10px;border-top:1px solid #2c3132}.user-search{display:flex;gap:6px}.user-search input{height:40px;min-width:240px;margin:0;padding:0 10px;font-size:var(--shd-type-telemetry)}.user-search button{height:40px}.user-row{min-width:1160px;grid-template-columns:1.1fr .65fr 1.1fr 1fr 1fr .9fr .3fr}.auth-method-summary{display:flex;flex-wrap:wrap;gap:3px}.auth-method-summary b{padding:3px 4px;border:1px solid #3a4840;color:#75c08c;font-size:var(--shd-type-telemetry);letter-spacing:.5px}.user-row>span>small{display:block;margin-bottom:3px;color:#f6a821;font-size:var(--shd-type-telemetry)}.restricted{color:#df8069!important}.row-action{justify-self:end;color:#f6a821;font-size:var(--shd-type-telemetry);font-weight:bold}.button-link{display:grid;place-items:center;height:40px;padding:0 14px;border:1px solid #424849;color:#d7d9d5;font-size:var(--shd-type-telemetry);font-weight:bold}.metric-date{font-size:var(--shd-type-telemetry)!important}.user-console-grid{display:grid;grid-template-columns:minmax(0,1fr) 380px;gap:10px}.user-console-grid .dashboard-editor{margin-top:0}.permission-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.moderation-panel .field{display:block}.console-note{padding:20px;color:#747c7e;font-size:var(--shd-type-telemetry)}.isac-auth-method-list{display:grid;border:1px solid #303536}.isac-auth-method{position:relative;min-height:45px;display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:6px;padding:7px 8px;border-top:1px solid #292e2f;background:#0d1011;overflow:hidden}.isac-auth-method:first-child{border-top:0}.isac-auth-method>div{display:grid;gap:3px}.isac-auth-method b{font-size:var(--shd-type-telemetry)}.isac-auth-method small{color:#697174;font-size:var(--shd-type-telemetry);line-height:1.35}.isac-auth-method>span{color:#626a6c;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.isac-auth-method>span.linked{color:#75c08c}.isac-auth-method.planned{opacity:.55}.isac-auth-method form,.isac-auth-method fieldset{height:100%}.isac-auth-method button{min-height:28px;height:100%;padding:0 7px;font-size:var(--shd-type-telemetry)}.isac-method-confirm{position:absolute;z-index:2;inset:0;display:grid!important;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:8px;padding:6px 8px;background:#211414;border:1px solid #9a5043}.isac-method-confirm>div:first-child{display:grid;gap:3px}.isac-method-confirm>div:first-child b{color:var(--shd-danger)}.isac-method-confirm>div:last-child{height:30px;display:grid;grid-template-columns:60px 96px;gap:5px}.isac-method-confirm .secondary{border:1px solid #565d5f;background:#101314;color:#d7d9d5}.isac-method-confirm .primary{background:var(--shd-orange);color:#111}.user-history-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.user-history-grid>section{min-width:0;border:1px solid #303536;background:#111415}.history-head{display:flex;justify-content:space-between;padding:10px;background:#0d1011;color:#727a7c;font-size:var(--shd-type-telemetry);letter-spacing:1px}.history-list{max-height:300px;overflow:auto}.history-list>div{display:grid;grid-template-columns:1fr auto auto;gap:8px;padding:9px 10px;border-top:1px solid #292e2f;font-size:var(--shd-type-telemetry)}.history-list span{color:#767e80}.history-list b{font-size:var(--shd-type-telemetry)}.history-list>p{padding:28px;text-align:center;color:#626a6c;font-size:var(--shd-type-telemetry)}.comment-history>div{grid-template-columns:1fr}.comment-history span{white-space:normal;line-height:1.4} -@media(max-width:1000px){.user-console-grid,.user-history-grid{grid-template-columns:1fr}.permission-grid{grid-template-columns:1fr 1fr}}@media(max-width:560px){.user-search{width:100%}.user-search input{min-width:0;flex:1}.permission-grid{grid-template-columns:1fr}} -.isac-operation-tabs{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));margin:10px 0 0;border:1px solid var(--shd-border);background:#0d1011}.isac-operation-tabs button{height:40px;border:0;border-left:1px solid var(--shd-border);border-bottom:2px solid transparent;background:transparent;color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1px;cursor:pointer}.isac-operation-tabs button:first-child{border-left:0}.isac-operation-tabs button b{margin-left:5px;color:var(--shd-success)}.isac-operation-tabs button:hover,.isac-operation-tabs button:focus-visible{background:#171b1c;color:var(--shd-text)}.isac-operation-tabs button.active{border-bottom-color:var(--shd-orange);background:#211a0e;color:var(--shd-orange)}.isac-operation-tabs button:focus-visible{position:relative;z-index:1;outline:2px solid var(--shd-orange);outline-offset:2px}.user-console-grid{grid-template-columns:minmax(0,1fr)}.isac-panel-hidden{display:none!important}.user-history-registries{display:grid;gap:10px;margin-top:10px}.history-registry{min-width:0;border:1px solid var(--shd-border);background:var(--shd-surface)}.history-filter{display:grid;grid-template-columns:minmax(180px,1fr) 170px 80px;gap:6px;padding:8px;border-bottom:1px solid var(--shd-border-soft)}.history-filter input,.history-filter select{height:34px;margin:0;padding:0 9px;font-size:var(--shd-type-telemetry)}.history-filter button{height:34px;padding:0 9px;font-size:var(--shd-type-telemetry)}.history-registry:last-child .history-filter{grid-template-columns:minmax(180px,1fr) 80px}.history-registry .registry-pagination{margin:0;border-width:0 0 1px}.history-registry .history-list{max-height:none}.history-registry .comment-history>div{grid-template-columns:minmax(140px,.5fr) minmax(260px,1.5fr) auto}.history-registry .comment-history span{white-space:normal;line-height:1.4}@media(max-width:650px){.isac-operation-tabs,.history-filter,.history-registry:last-child .history-filter{grid-template-columns:1fr}.isac-operation-tabs button{border-left:0;border-top:1px solid var(--shd-border)}.isac-operation-tabs button:first-child{border-top:0}} -.isac-modal-backdrop{position:fixed;z-index:50;inset:0;display:grid;place-items:center;padding:20px;background:#050607d9;backdrop-filter:blur(3px)}.isac-confirm-modal{width:min(680px,100%);max-height:calc(100vh - 40px);overflow:auto;padding:16px;border:1px solid #5a6061;border-top:2px solid var(--shd-orange);background:#131617;box-shadow:0 24px 80px #000}.access-confirm-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}.access-confirm-grid>div{min-height:120px;align-content:start;display:grid;gap:5px;padding:10px;border:1px solid var(--shd-border);background:#0d1011}.access-confirm-grid>div>span{margin-bottom:3px;color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);letter-spacing:1px}.access-confirm-grid b{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:6px 7px;border-left:2px solid #596164;background:#151819;color:#aeb3b4;font-size:var(--shd-type-telemetry)}.access-confirm-grid b span{color:inherit}.access-confirm-grid b em{font-style:normal;color:#747c7e;font-size:var(--shd-type-telemetry);letter-spacing:.8px}.access-confirm-grid b.access-add{border-left-color:var(--shd-success);background:#101713;color:#9bd4ac}.access-confirm-grid b.access-add em{color:var(--shd-success)}.access-confirm-grid b.access-remove{border-left-color:var(--shd-danger);background:#211414;color:#dca096;text-decoration:line-through}.access-confirm-grid b.access-remove em{color:var(--shd-danger);text-decoration:none}.access-confirm-grid small{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry)}@media(max-width:560px){.access-confirm-grid{grid-template-columns:1fr}} -.login-investigation-list>div{grid-template-columns:1fr 1fr 1.2fr auto;align-items:center}.login-investigation-list>div>div{display:grid;gap:4px}.login-investigation-list small{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);letter-spacing:.4px}.deletion-purge-option{display:grid!important;grid-template-columns:20px 1fr;align-items:start;gap:8px;margin:14px 0!important;padding:10px;border:1px solid #70423b;background:#211414}.deletion-purge-option input{width:auto;margin:2px 0 0;accent-color:var(--shd-danger)}.deletion-purge-option span{display:grid;gap:4px}.deletion-purge-option b{color:var(--shd-danger);font-size:var(--shd-type-telemetry)}.deletion-purge-option small{margin:0;color:#b18c85;font-size:var(--shd-type-telemetry)} -.site-footer{position:relative;z-index:2;min-height:150px;display:grid;grid-template-columns:1fr auto minmax(300px,.9fr);align-items:center;gap:28px;padding:28px max(24px,5vw);border-top:1px solid var(--shd-border);background:#080a0b}.footer-brand{width:max-content}.site-footer>div>p{margin:8px 0 0;color:var(--shd-text-dim);font:11px var(--shd-font-editorial)}.footer-links{display:grid;gap:8px}.footer-links a{color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1px}.fan-disclaimer{min-width:0;margin:0;color:#555d5f;font-size:var(--shd-type-telemetry);line-height:1.6;letter-spacing:.55px;overflow-wrap:anywhere}.legal-page{max-width:1320px;margin:auto;padding:55px clamp(18px,5vw,72px) 90px}.legal-hero{padding-bottom:30px;border-bottom:1px solid var(--shd-border)}.legal-hero h1{margin:18px 0 14px;font-size:clamp(48px,7vw,82px);line-height:.9;letter-spacing:-3px}.legal-hero h1 em{color:var(--shd-orange);font-style:normal}.legal-hero>p:last-child{max-width:720px;color:var(--shd-text-muted);font:15px/1.6 var(--shd-font-editorial)}.legal-layout{display:grid;grid-template-columns:210px minmax(0,1fr);gap:28px;margin-top:24px}.legal-index{position:sticky;top:95px;height:max-content;display:grid;margin:0;border:1px solid var(--shd-border)}.legal-index a{padding:11px;border-top:1px solid var(--shd-border-soft);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.legal-index a:first-child{border-top:0}.legal-index a:hover{color:var(--shd-orange);background:#171b1c}.legal-document{display:grid}.legal-document section{scroll-margin-top:90px;display:grid;grid-template-columns:42px 1fr;gap:14px;padding:24px 0;border-top:1px solid var(--shd-border)}.legal-document section:first-child{border-top:0;padding-top:0}.legal-document section>span{color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900}.legal-document h2{font-size:18px;letter-spacing:.6px}.legal-document p{color:#a1a7a8;font:13px/1.7 var(--shd-font-editorial)}.legal-document a{color:var(--shd-orange)}@media(max-width:1100px){.site-footer{grid-template-columns:1fr}.legal-layout{grid-template-columns:1fr}.legal-index{position:static;grid-template-columns:1fr 1fr}.login-investigation-list>div{grid-template-columns:1fr 1fr}}@media(max-width:500px){.legal-index,.login-investigation-list>div{grid-template-columns:1fr}} -.legal-primary{width:max-content;color:#111!important;margin-top:8px}.contact-page{width:100%;max-width:1500px;margin:auto;padding:34px clamp(18px,4vw,58px) 52px}.contact-hero{position:relative;height:auto;min-height:230px;display:grid;grid-template-columns:minmax(0,1fr) 310px;align-items:center;gap:40px;padding:18px 0 30px;border-bottom:1px solid var(--shd-border);background:transparent}.contact-hero>div{min-width:0}.contact-hero h1{margin:18px 0 14px;font-size:clamp(52px,7vw,88px);line-height:.85;letter-spacing:-4px}.contact-hero h1 em{color:var(--shd-orange);font-style:normal}.contact-hero>div>p:last-child{max-width:690px;margin-bottom:0;color:var(--shd-text-muted);font:15px/1.6 var(--shd-font-editorial)}.discord-contact{min-height:82px;display:grid;grid-template-columns:1fr auto;align-content:center;gap:4px 10px;padding:15px 17px;border:1px solid #6571dc;background:#171a32;color:#aeb6ff}.discord-contact span{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1.2px}.discord-contact strong{font-size:var(--shd-type-telemetry);letter-spacing:.8px}.discord-contact b{grid-column:2;grid-row:1/3;align-self:center;font-size:18px}.discord-contact:hover,.discord-contact:focus-visible{border-color:#929bff;background:#202446;color:#fff}.contact-layout{display:grid;grid-template-columns:minmax(0,1fr) 320px;align-items:start;gap:12px;margin-top:12px}.contact-console,.contact-guidance{border:1px solid var(--shd-border);background:var(--shd-surface)}.contact-console{padding:16px}.contact-console-head{min-height:45px;display:flex;align-items:start;justify-content:space-between;padding-bottom:10px;margin-bottom:13px;border-bottom:1px solid var(--shd-border)}.contact-console-head>div{display:grid;gap:4px}.contact-console-head span{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);letter-spacing:1.2px}.contact-console-head strong{font-size:13px}.contact-console-head>b{color:var(--shd-success);font-size:var(--shd-type-telemetry);letter-spacing:1px}.contact-console .segmented-choice>div{height:40px}.contact-console .field{margin:10px 0}.contact-console textarea{line-height:1.5}.contact-identity{display:grid;grid-template-columns:1fr 1fr;gap:9px}.contact-honeypot{position:absolute;left:-10000px;width:1px;height:1px;overflow:hidden}.dmca-contact-fields{margin-top:15px;padding:12px;border:1px solid #74552a;border-left:2px solid var(--quality-high-end);background:#131108}.dmca-contact-fields>header{height:auto;display:flex;align-items:center;justify-content:space-between;padding:0 0 9px;border-bottom:1px solid #493a22;background:transparent}.dmca-contact-fields>header>div{display:grid;gap:4px}.dmca-contact-fields>header span{color:#82755f;font-size:var(--shd-type-telemetry);letter-spacing:1px}.dmca-contact-fields>header strong{color:var(--quality-high-end);font-size:var(--shd-type-telemetry)}.dmca-contact-fields>header a{color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900}.dmca-contact-fields>p{margin:10px 0;color:#9b927f;font:var(--shd-type-supporting)/1.55 var(--shd-font-editorial)}.legal-attestation{min-height:46px;display:grid;grid-template-columns:auto 1fr;align-items:center;gap:9px;margin-top:7px;padding:8px;border:1px solid #3c382e;background:#0d0f0d;color:#b5b0a3;font-size:var(--shd-type-telemetry);line-height:1.45;cursor:pointer}.legal-attestation input{width:auto;margin:0;accent-color:var(--shd-orange)}.legal-attestation:has(input:checked){border-color:#7c5c29;background:#1b170d}.contact-guidance{height:max-content;display:grid}.contact-guidance>div{position:relative;min-height:126px;padding:16px 16px 14px 50px;border-top:1px solid var(--shd-border)}.contact-guidance>div:first-child{border-top:0}.contact-guidance>div>span{position:absolute;left:15px;top:17px;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900}.contact-guidance h2{font-size:13px;letter-spacing:.8px}.contact-guidance p{margin:0;color:var(--shd-text-muted);font:var(--shd-type-supporting)/1.55 var(--shd-font-editorial)}.contact-guidance a{display:inline-block;margin-top:10px;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900}.contact-guidance>small{padding:14px;border-top:1px solid var(--shd-border);color:var(--shd-danger);font-size:var(--shd-type-telemetry);line-height:1.5;letter-spacing:.6px}.contact-success{min-height:300px;display:flex;align-items:center;justify-content:center;gap:16px;text-align:left}.contact-success>span{width:48px;height:48px;display:grid;place-items:center;border:1px solid var(--shd-success);color:var(--shd-success);font-size:18px}.contact-success strong{font-size:13px;letter-spacing:1px}.contact-success p{max-width:430px;margin:7px 0 0;color:var(--shd-text-muted);font:13px/1.55 var(--shd-font-editorial)}.contact-message-page{width:100%;max-width:1200px;min-height:calc(100vh - var(--shd-header-height));margin:auto;padding:36px clamp(18px,4vw,55px) 70px}.contact-back{display:inline-block;margin-bottom:12px;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.contact-message{border:1px solid var(--shd-border);border-top:2px solid var(--shd-orange);background:var(--shd-surface)}.contact-message>header{position:static;height:62px;display:flex;justify-content:space-between;padding:0 17px;border-bottom:1px solid var(--shd-border);background:#0d1011}.contact-message>header>div{display:grid;gap:4px}.contact-message>header span{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);letter-spacing:1px}.contact-message>header strong{font-size:var(--shd-type-telemetry)}.contact-message>header time{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry)}.contact-message-title{padding:28px clamp(20px,5vw,55px);border-bottom:1px solid var(--shd-border)}.contact-message-title h1{margin:12px 0;font-size:clamp(30px,5vw,48px)}.contact-message-title>p:last-child{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.contact-message-title a,.contact-message section a{color:var(--shd-orange)}.contact-message>section{padding:24px clamp(20px,5vw,55px);border-bottom:1px solid var(--shd-border)}.contact-message>section h2{font-size:var(--shd-type-telemetry);letter-spacing:1px}.contact-message>section>p{margin:12px 0 0;color:#c0c4c5;font:14px/1.7 var(--shd-font-editorial);white-space:pre-wrap}.contact-dmca-record dl{display:grid;gap:10px}.contact-dmca-record dl>div{display:grid;grid-template-columns:160px 1fr;gap:12px;padding-top:10px;border-top:1px solid var(--shd-border-soft)}.contact-dmca-record dt{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.contact-dmca-record dd{margin:0;color:#c0c4c5;font:12px/1.6 var(--shd-font-editorial);white-space:pre-wrap}.contact-message>footer{display:flex;justify-content:flex-end;padding:14px}.contact-message>footer .primary{padding:13px 16px}@media(max-width:900px){.contact-hero,.contact-layout{grid-template-columns:1fr}.contact-hero{min-height:0;align-items:start;gap:20px;padding-top:8px}.discord-contact{width:100%}}@media(max-width:600px){.contact-page{padding-top:24px;padding-bottom:36px}.contact-hero h1{letter-spacing:-2.5px}.contact-identity{grid-template-columns:1fr}.contact-console .segmented-choice>div{height:auto;grid-template-columns:1fr}.contact-console .segmented-choice span{min-height:44px}.dmca-contact-fields>header{align-items:flex-start;flex-direction:column;gap:8px}.contact-dmca-record dl>div{grid-template-columns:1fr}.contact-message>footer .primary{width:100%}} -@media(max-width:1100px){.effect-editor{grid-template-columns:1fr}.selection-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:900px){.gear-identity,.gear-ranges{grid-template-columns:1fr 1fr}}@media(max-width:560px){.effect-band{grid-template-columns:48px 1fr}.effect-band .field:last-child{grid-column:2}.split-actions{align-items:stretch}.split-actions,.split-actions>div{width:100%}.split-actions>div:last-child button{flex:1}.gear-identity,.gear-ranges,.selection-grid{grid-template-columns:1fr}.range-band{grid-template-columns:48px 1fr}.range-band .field:last-child{grid-column:2}} -.game-icon{display:block;width:24px;height:24px;object-fit:contain}.weapon-editor{border-top-color:#d07a2c}.weapon-identity-grid{display:grid;grid-template-columns:1.6fr repeat(5,minmax(0,1fr));gap:8px}.weapon-fixed-grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:8px}.damage-type-picker{min-width:0;margin:0;padding:0;border:0}.damage-type-picker legend{color:#858b8d;font-size:var(--shd-type-telemetry);font-weight:bold;letter-spacing:1px}.damage-type-picker>div{height:32px;display:grid;grid-template-columns:repeat(3,1fr);margin-top:5px;border:1px solid #363b3c;background:#0d0f10}.damage-type-picker label{min-width:0;border-left:1px solid #303536;cursor:pointer}.damage-type-picker label:first-child{border-left:0}.damage-type-picker input{position:absolute;opacity:0;pointer-events:none}.damage-type-picker span{height:100%;display:flex;align-items:center;justify-content:center;gap:3px;color:#737a7c}.damage-type-picker .game-icon{width:17px;height:17px;opacity:.62}.damage-type-picker b{overflow:hidden;font-size:var(--shd-type-telemetry);letter-spacing:.25px;text-overflow:ellipsis}.damage-type-picker label:hover span{background:#171b1c;color:#ecebe5}.damage-type-picker label:active span{transform:translateY(1px);filter:brightness(.88)}.damage-type-picker input:checked+span{background:#211a0e;color:#f6a821;box-shadow:inset 0 -2px #f6a821}.damage-type-picker input:checked+span .game-icon{opacity:1}.damage-type-picker input:focus-visible+span{outline:2px solid #f6a821;outline-offset:2px}.exotic-toggle>span{min-height:32px;margin-top:5px;padding:0 9px;display:flex;align-items:center;gap:7px;border:1px solid #363b3c;background:#0d0f10;color:#d3d5d1;font-size:var(--shd-type-telemetry)}.exotic-toggle input{width:auto;margin:0;accent-color:#f6a821}.weapon-tier-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin-top:9px}.weapon-tier-card{min-width:0;margin:0;padding:7px;border:1px solid #3b4142;background:#101314}.weapon-tier-card legend{padding:0 7px;color:#f6a821;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1.2px}.weapon-range-row{display:grid;grid-template-columns:65px 1fr 1fr;gap:5px;align-items:end;padding:5px;border-top:1px solid #282e2f}.weapon-range-row:first-of-type{border-top:0}.weapon-range-row>strong{align-self:center;display:flex;align-items:center;gap:4px;color:#d7d9d5;font-size:var(--shd-type-telemetry)}.weapon-range-row>strong .game-icon{width:20px;height:20px}.weapon-range-row>strong small{display:block;color:#596164;font-size:var(--shd-type-telemetry);letter-spacing:.5px}.weapon-range-row label{color:#737a7c;font-size:var(--shd-type-telemetry);letter-spacing:.7px}.weapon-range-row input{margin-top:3px;padding:7px;font-size:var(--shd-type-telemetry)}.weapon-talent-pools{display:grid;grid-template-columns:1fr 1fr;gap:9px}.compact-selection{grid-template-columns:repeat(2,minmax(0,1fr));max-height:210px;overflow:auto}.weapon-base-bonuses,.weapon-augment-effects{display:grid;gap:5px;margin-top:7px}.base-bonus-row,.augment-effect{display:grid;grid-template-columns:35px minmax(180px,1fr) 130px 30px;gap:6px;align-items:center;padding:5px;border:1px solid #303536;background:#101314}.base-bonus-row>strong,.augment-effect>strong{height:100%;display:grid;place-items:center;background:#171b1c;color:#f6a821;font-size:var(--shd-type-telemetry)}.base-bonus-row .field,.augment-effect .field{margin:0}.base-bonus-row button,.augment-effect button{height:30px;border:1px solid #563734;background:#1d1212;color:#da746c;cursor:pointer}.base-bonus-row button:disabled{opacity:.3;cursor:not-allowed}.augment-effect:not(:has(>button)){grid-template-columns:35px minmax(180px,1fr) 130px}.augment-sequence{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:5px;margin-top:7px}.augment-sequence>div{min-height:42px;display:grid;place-items:center;padding:5px;border:1px solid #303536;background:#0d1011}.augment-sequence b{color:#f6a821;font-size:var(--shd-type-telemetry)}.augment-sequence span{color:#727a7c;font-size:var(--shd-type-telemetry)}.augment-sequence .exotic-step{border-color:#875d25;background:#211a0e}.weapon-row{min-width:1180px;grid-template-columns:1.3fr .7fr 1.2fr .6fr .6fr .5fr .5fr .3fr}.weapon-row>span small{color:#596164}.weapon-damage-label{display:flex!important;align-items:center;gap:4px}.weapon-damage-label .game-icon{width:16px;height:16px}.weapon-dashboard-table{margin-top:10px} -.assignment-picker{border:1px solid #303536;background:#0d1011}.assignment-selected{display:grid;grid-template-columns:70px 1fr;min-height:45px;border-bottom:1px solid #303536}.assignment-summary{display:grid;place-items:center;background:#15191a}.assignment-summary span{color:#687073;font-size:var(--shd-type-telemetry);letter-spacing:1px}.assignment-summary b{color:#f6a821;font-size:14px}.assignment-chips{display:flex;align-items:center;gap:5px;overflow:auto;padding:6px}.assignment-chips>button{flex:0 0 auto;display:flex;align-items:center;gap:7px;padding:6px 8px;border:1px solid #6b5128;background:#211a0e;color:#e2cda7;font-size:var(--shd-type-telemetry);cursor:pointer}.assignment-chips>button:hover{border-color:#f6a821;color:#f6a821}.assignment-chips>button:active{transform:translateY(1px)}.assignment-chips small{color:#596164;font-size:var(--shd-type-telemetry)}.assignment-search{display:block;padding:7px 8px;color:#70787a;font-size:var(--shd-type-telemetry);letter-spacing:1px}.assignment-search input{height:32px;margin-top:5px;padding:0 9px;font-size:var(--shd-type-telemetry)}.assignment-results-head{height:27px;display:flex;align-items:center;justify-content:space-between;padding:0 9px;border-top:1px solid #262b2c;border-bottom:1px solid #262b2c;color:#687073;font-size:var(--shd-type-telemetry);letter-spacing:1px}.assignment-results-head b{color:#8a672b}.assignment-results{max-height:210px;overflow:auto}.assignment-results>button{width:100%;min-height:34px;display:flex;align-items:center;justify-content:space-between;padding:6px 9px;border:0;border-top:1px solid #24292a;background:#111415;color:#a4aaac;text-align:left;font-size:var(--shd-type-telemetry);cursor:pointer}.assignment-results>button:first-child{border-top:0}.assignment-results>button:hover,.assignment-results>button:focus-visible{background:#171b1c;color:#fff;box-shadow:inset 2px 0 #f6a821}.assignment-results>button.selected{background:#211a0e;color:#f6a821;box-shadow:inset 2px 0 #f6a821}.assignment-results>button b{font-size:var(--shd-type-telemetry);letter-spacing:.7px}.assignment-results>button:active{transform:translateY(1px);filter:brightness(.88)}.assignment-results>p{margin:0;padding:25px;text-align:center;color:#596164;font-size:var(--shd-type-telemetry);letter-spacing:1px} -.assignment-picker.compact{position:relative;z-index:1;display:grid;grid-template-columns:minmax(120px,.85fr) minmax(135px,1fr);min-width:0}.assignment-picker.compact:focus-within,.assignment-picker.compact.open{z-index:20;border-color:#8a672b}.assignment-picker.compact .assignment-selected{grid-template-columns:42px minmax(0,1fr);min-width:0;min-height:40px;border-right:1px solid var(--shd-border);border-bottom:0}.assignment-picker.compact .assignment-summary span{font-size:var(--shd-type-telemetry)}.assignment-picker.compact .assignment-summary b{font-size:var(--shd-type-telemetry)}.assignment-picker.compact .assignment-chips{min-width:0;padding:4px}.assignment-picker.compact .assignment-chips>button{max-width:100%;padding:5px 6px}.assignment-picker.compact .assignment-chips>button span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.assignment-picker.compact .assignment-search{display:grid;align-content:center;padding:4px}.assignment-picker.compact .assignment-search>span{display:none}.assignment-picker.compact .assignment-search input{height:32px;margin:0}.assignment-picker.compact .assignment-results-head,.assignment-picker.compact .assignment-results{display:none;position:absolute;left:-1px;right:-1px;background:#0d1011}.assignment-picker.compact.open .assignment-results-head{top:100%;display:flex;border:1px solid #8a672b;border-bottom-color:var(--shd-border)}.assignment-picker.compact.open .assignment-results{top:calc(100% + 27px);display:block;border:1px solid #8a672b;border-top:0;box-shadow:0 12px 24px #000b}.assignment-picker.compact .assignment-results>button{min-height:38px}@media(max-width:600px){.assignment-picker.compact{grid-template-columns:1fr}.assignment-picker.compact .assignment-selected{border-right:0;border-bottom:1px solid var(--shd-border)}} -.registry-pagination{width:100%;height:auto;min-height:48px;margin:8px 0 0;padding:7px 9px;display:grid;grid-template-columns:minmax(190px,260px) auto 1fr auto;align-items:center;gap:12px;border:1px solid #303536;border-bottom:0;background:#0f1213}.registry-pagination:not(:has(.pagination-search)){grid-template-columns:auto 1fr auto}.pagination-search{min-width:0;display:grid;grid-template-columns:minmax(100px,1fr) auto auto;align-items:center;gap:4px}.pagination-search input[type=search]{min-width:0;height:30px;margin:0;padding:0 8px;font-size:var(--shd-type-telemetry)}.pagination-search button,.pagination-search>a{height:30px;display:grid;place-items:center;margin:0;padding:0 9px;border:1px solid #3b4142;background:#111415;color:#8c9395;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.6px;cursor:pointer}.pagination-search button:hover,.pagination-search button:focus-visible,.pagination-search>a:hover,.pagination-search>a:focus-visible{border-color:#8a672b;color:var(--shd-orange)}.pagination-search button:active,.pagination-search>a:active{transform:translateY(1px);filter:brightness(.88)}.pagination-range{display:flex;align-items:center;gap:6px;color:#697174;font-size:var(--shd-type-telemetry);letter-spacing:.8px}.pagination-range b{color:#d7d9d5;font-size:var(--shd-type-telemetry)}.pagination-pages{display:flex;align-items:center;justify-content:center;gap:4px}.pagination-pages a,.pagination-sizes a{min-width:30px;height:30px;display:grid;place-items:center;border:1px solid #343a3b;background:#111415;color:#8c9395;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.5px;cursor:pointer}.pagination-pages a:first-child,.pagination-pages a:last-child{min-width:58px;padding:0 8px}.pagination-pages a:hover,.pagination-pages a:focus-visible,.pagination-sizes a:hover,.pagination-sizes a:focus-visible{border-color:#8a672b;color:#f6a821}.pagination-pages a.active,.pagination-sizes a.active{border-color:#f6a821;background:#211a0e;color:#f6a821}.pagination-pages a:active,.pagination-sizes a:active{transform:translateY(1px);filter:brightness(.88)}.pagination-pages a.disabled{opacity:.35;pointer-events:none}.pagination-gap{color:#555d5f;font-size:var(--shd-type-telemetry)}.pagination-sizes{display:flex;align-items:center;gap:4px}.pagination-sizes>span{margin-right:3px;color:#697174;font-size:var(--shd-type-telemetry);letter-spacing:1px}.pagination-sizes a{min-width:34px}.registry-pagination a:focus-visible{outline:2px solid #f6a821;outline-offset:2px} -.registry-pagination+.dashboard-table{margin-top:0} -@media(max-width:1100px){.registry-pagination,.registry-pagination:not(:has(.pagination-search)){grid-template-columns:1fr 1fr}.pagination-search{grid-column:1/3}.pagination-pages{justify-content:flex-end}}@media(max-width:650px){.registry-pagination,.registry-pagination:not(:has(.pagination-search)){grid-template-columns:1fr}.pagination-search{grid-column:auto}.pagination-range,.pagination-pages,.pagination-sizes{justify-content:center}}@media(max-width:560px){.pagination-search{grid-template-columns:minmax(0,1fr) auto}.pagination-search>a{grid-column:1/3}.pagination-pages a:not(:first-child):not(:last-child):not(.active),.pagination-gap{display:none}.pagination-sizes>span{display:none}} -@media(max-width:1200px){.weapon-identity-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.weapon-fixed-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.weapon-tier-grid{grid-template-columns:1fr}.weapon-talent-pools{grid-template-columns:1fr}}@media(max-width:700px){.weapon-identity-grid,.weapon-fixed-grid{grid-template-columns:1fr 1fr}.compact-selection{grid-template-columns:1fr}.base-bonus-row,.augment-effect,.augment-effect:not(:has(>button)){grid-template-columns:30px 1fr}.base-bonus-row .field:nth-of-type(2),.augment-effect .field:nth-of-type(2){grid-column:2}.base-bonus-row>button,.augment-effect>button{grid-column:2}.augment-sequence{grid-template-columns:1fr 1fr}}@media(max-width:480px){.weapon-identity-grid,.weapon-fixed-grid{grid-template-columns:1fr}.weapon-range-row{grid-template-columns:45px 1fr 1fr}} -.weapon-tier-card.unavailable{position:relative;opacity:.42;border-color:#292d2e}.weapon-tier-card.unavailable .weapon-range-row{filter:grayscale(1)}.tier-unavailable{margin:0;padding:7px;border-bottom:1px solid #282e2f;background:#0d1011;color:#7b8284;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px;text-align:center}.core-range-row>strong{gap:7px}.core-icon-stack{display:flex;align-items:center}.core-icon-stack .game-icon{width:17px;height:17px;margin-left:-7px}.core-icon-stack .game-icon:first-child{margin-left:0}.weapon-archive-action{min-height:40px;padding:0 13px}.weapon-archive-action>b{font-size:var(--shd-type-telemetry);transform:rotate(90deg)} -.legal-table-wrap{overflow-x:auto;margin:18px 0}.legal-table{width:100%;min-width:720px;border-collapse:collapse;border:1px solid var(--shd-border);background:#0b0e0f}.legal-table th,.legal-table td{padding:12px 14px;border:1px solid var(--shd-border);text-align:left;vertical-align:top}.legal-table th{color:var(--shd-orange);background:#111516;font-size:var(--shd-type-telemetry);letter-spacing:1px}.legal-table td{color:#a1a7a8;font:12px/1.55 var(--shd-font-editorial)}.legal-table code,.legal-document code{color:#d9dedf;font:11px Consolas,monospace}.cookie-notice{position:fixed;z-index:1000;right:24px;bottom:24px;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:end;gap:22px;width:min(620px,calc(100vw - 48px));padding:18px 20px;border:1px solid var(--shd-orange);background:rgba(10,13,14,.98);box-shadow:0 14px 48px #000c,4px 0 0 var(--shd-orange)}.cookie-notice .eyebrow{margin:0 0 8px}.cookie-notice div>p:not(.eyebrow){margin:0;color:var(--shd-text-muted);font:12px/1.5 var(--shd-font-editorial)}.cookie-notice a{display:inline-block;margin-top:10px;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1px}.cookie-notice button{min-width:84px}@media(max-width:600px){.cookie-notice{right:12px;bottom:12px;grid-template-columns:1fr;width:calc(100vw - 24px)}.cookie-notice button{width:100%}} -.kpi-filters{height:auto}.kpi-filters>a{position:relative;min-height:70px;padding:11px 13px;background:#111415;border:1px solid #2d3233;border-top:2px solid #3a4041;display:grid;color:inherit;cursor:pointer}.kpi-filters>a:hover,.kpi-filters>a:focus-visible{border-color:#8a672b;border-top-color:var(--shd-orange);background:#171a1b}.kpi-filters>a.active{border-color:#8a672b;border-top-color:var(--shd-orange);background:#211a0e;box-shadow:inset 0 -2px #f6a821}.kpi-filters>a:active{transform:translateY(1px);filter:brightness(.88)}.kpi-filters>a span{font-size:var(--shd-type-telemetry);letter-spacing:1.3px;color:#737a7c}.kpi-filters>a strong{font-size:23px;line-height:1.1;margin:3px 0}.kpi-filters>a small{font-size:var(--shd-type-telemetry);color:#555d5f}.kpi-filters>a.active span,.kpi-filters>a.active strong{color:var(--shd-orange)} -.role-kpis{grid-template-columns:repeat(5,minmax(105px,1fr))}.role-identity{grid-template-columns:minmax(220px,.7fr) minmax(320px,1.3fr)!important}.permission-catalogue{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;margin-top:8px}.permission-catalogue label{min-height:54px;display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:9px;padding:8px 10px;border:1px solid #303536;background:#0d1011;cursor:pointer}.permission-catalogue label:hover,.permission-catalogue label:focus-within{border-color:#8a672b;background:#151819}.permission-catalogue label:active{transform:translateY(1px);filter:brightness(.88)}.permission-catalogue input{width:auto;margin:0;accent-color:var(--shd-orange)}.permission-catalogue span{display:grid;gap:4px}.permission-catalogue b{color:#d7d9d5;font-size:var(--shd-type-telemetry)}.permission-catalogue small{color:#626a6c;font-size:var(--shd-type-telemetry);line-height:1.35}.permission-catalogue em{color:#596164;font-size:var(--shd-type-telemetry);font-style:normal;font-weight:900;letter-spacing:.7px}.permission-catalogue label:has(input:checked){border-color:#6e5226;background:#211a0e}.permission-catalogue label:has(input:checked) em{color:var(--shd-orange)}.role-row{min-width:960px;grid-template-columns:1.1fr .55fr 2fr .65fr .3fr}.role-permission-summary{display:flex!important;flex-wrap:wrap;gap:3px}.role-permission-summary b{padding:3px 5px;border:1px solid #3a4840;color:#75c08c;font-size:var(--shd-type-telemetry);letter-spacing:.5px}@media(max-width:1000px){.role-kpis{grid-template-columns:repeat(3,minmax(105px,1fr))}}@media(max-width:700px){.permission-catalogue,.role-identity{grid-template-columns:1fr!important}.role-kpis{grid-template-columns:1fr 1fr}} -.audit-toolbar>div:last-child{display:block}.audit-filter{height:auto;display:grid;grid-template-columns:minmax(220px,1fr) 210px 240px auto;gap:7px;margin:10px 0}.audit-filter input,.audit-filter select,.audit-filter button{height:40px;margin:0}.audit-filter button{display:inline-flex;align-items:center;justify-content:center;padding:0 14px;line-height:1}.audit-row{min-width:980px;grid-template-columns:1fr 1fr 1.3fr .85fr .35fr}.audit-entry{border-top:1px solid #272c2d}.audit-entry:first-of-type{border-top:0}.audit-entry summary{border:0;cursor:pointer;list-style:none}.audit-entry summary::-webkit-details-marker{display:none}.audit-entry summary:hover{background:#15191a}.audit-entry summary:active{transform:translateY(1px);filter:brightness(.88)}.audit-entry summary>b{justify-self:end;color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.7px}.audit-entry[open] summary{background:#171a1b;box-shadow:inset 2px 0 var(--shd-orange)}.audit-comparison{display:grid;grid-template-columns:1fr 1fr;gap:1px;padding:1px;background:#303536}.audit-comparison section{min-width:0;padding:12px;background:#0b0e0f}.audit-comparison span{color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1px}.audit-comparison pre{max-height:320px;overflow:auto;margin:9px 0 0;color:#aeb4b5;font:var(--shd-type-telemetry)/1.5 Consolas,monospace;white-space:pre-wrap;word-break:break-word}@media(max-width:900px){.audit-filter{grid-template-columns:1fr 1fr}.audit-comparison{grid-template-columns:1fr}}@media(max-width:560px){.audit-filter{grid-template-columns:1fr}} -.audit-kpis{grid-template-columns:repeat(5,minmax(120px,1fr))} -.access-baseline-note{display:flex;align-items:center;gap:10px;margin:9px 0;padding:8px 10px;border-left:2px solid var(--shd-info);background:#0e1518;color:#94c3d5}.access-baseline-note b{flex:0 0 auto;font-size:var(--shd-type-telemetry);letter-spacing:1px}.access-baseline-note span{color:#6f929f;font-size:var(--shd-type-telemetry)}.access-baseline-note.compact{margin:0 0 8px} -.audit-revert-bar{min-height:56px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 12px;border-top:1px solid #4c302d;background:#1c1111}.audit-revert-bar>span{color:#a87870;font-size:var(--shd-type-telemetry);letter-spacing:.8px}.audit-revert-button{min-width:174px;height:40px;padding:0 14px}.audit-revert-button>b{font-size:15px;line-height:1}.audit-revert-button:hover,.audit-revert-button:focus-visible{box-shadow:inset 3px 0 var(--shd-danger)}.audit-revert-bar.completed{border-color:#36563f;background:#101713}.audit-revert-bar.completed>span{color:var(--shd-success)}.audit-revert-modal>p{color:#969d9f;font:13px/1.6 var(--shd-font-editorial)}.audit-revert-modal code{color:#d7d9d5;font:11px Consolas,monospace}.audit-revert-summary{display:grid;grid-template-columns:1fr 1fr 1.4fr;gap:7px;margin:14px 0}.audit-revert-summary>div{min-width:0;display:grid;gap:5px;padding:9px;border:1px solid #303536;background:#0d1011}.audit-revert-summary span{color:#626a6c;font-size:var(--shd-type-telemetry);letter-spacing:.8px}.audit-revert-summary b{overflow:hidden;text-overflow:ellipsis;color:#d7d9d5;font-size:var(--shd-type-telemetry);white-space:nowrap}@media(max-width:600px){.audit-revert-bar{align-items:stretch;flex-direction:column}.audit-revert-button{width:100%}.audit-revert-summary{grid-template-columns:1fr}} -.metrics-auto{height:40px;display:flex!important;align-items:center;gap:7px;padding:0 10px;border:1px solid var(--shd-border);background:#0d1011;color:#747c7e;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px;cursor:pointer}.metrics-auto input{width:auto;margin:0;accent-color:var(--shd-orange)}.metrics-auto:has(input:checked){border-color:#6e5226;background:#211a0e;color:var(--shd-orange)}.metrics-refresh{min-width:154px}.metrics-kpis strong{font-size:18px}.metrics-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}.metrics-panel{min-width:0;border:1px solid var(--shd-border);background:var(--shd-surface)}.metrics-panel>header{min-height:64px;display:flex;align-items:center;justify-content:space-between;gap:14px;padding:11px 13px;border-bottom:1px solid var(--shd-border);background:#0d1011}.metrics-panel h3{margin:4px 0 0;font-size:14px;letter-spacing:.5px}.metrics-panel>header>b{color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:1px}.metric-lines{display:grid}.metric-lines>div{min-height:40px;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:8px 12px;border-top:1px solid #252a2b}.metric-lines>div:first-child{border-top:0}.metric-lines span{color:#697174;font-size:var(--shd-type-telemetry);letter-spacing:.9px}.metric-lines b{max-width:65%;overflow:hidden;color:#d5d8d5;font-size:var(--shd-type-telemetry);text-align:right;text-overflow:ellipsis;white-space:nowrap}.metrics-foot{margin:10px 0 0;padding:9px;border:1px solid #292e2f;color:#596164;font-size:var(--shd-type-telemetry);letter-spacing:.8px}@media(max-width:850px){.metrics-grid{grid-template-columns:1fr}}@media(max-width:560px){.metrics-auto,.metrics-refresh{width:100%}.metric-lines b{max-width:58%}} -.quality-chip{display:inline-flex;width:max-content;align-items:center;padding:4px 6px;border:1px solid currentColor;background:#1115;color:var(--quality-standard);font-size:var(--shd-type-telemetry);letter-spacing:.7px;text-transform:uppercase}.quality-standard{color:var(--quality-standard)!important}.quality-specialized{color:var(--quality-specialized)!important}.quality-superior{color:var(--quality-superior)!important}.quality-highend{color:var(--quality-high-end)!important} -.protocol-editor{border-top-color:var(--quality-specialized)}.protocol-identity-grid{display:grid;grid-template-columns:1.4fr .7fr 1.5fr;gap:8px}.protocol-type-picker{min-width:0;margin:0;padding:0;border:0}.protocol-type-picker legend{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:bold;letter-spacing:1px}.protocol-type-picker>div{height:32px;display:grid;grid-template-columns:repeat(3,1fr);margin-top:5px;border:1px solid #363b3c;background:#0d0f10}.protocol-type-picker label{min-width:0;border-left:1px solid var(--shd-border);cursor:pointer}.protocol-type-picker label:first-child{border-left:0}.protocol-type-picker input{position:absolute;opacity:0;pointer-events:none}.protocol-type-picker span{height:100%;display:flex;align-items:center;justify-content:center;gap:4px;color:#737a7c}.protocol-type-picker .game-icon{width:18px;height:18px;opacity:.55}.protocol-type-picker b{font-size:var(--shd-type-telemetry)}.protocol-type-picker label:hover span{background:#171b1c;color:var(--shd-text)}.protocol-type-picker label:active span{transform:translateY(1px);filter:brightness(.88)}.protocol-type-picker input:checked+span{background:#211a0e;color:var(--shd-orange);box-shadow:inset 0 -2px var(--shd-orange)}.protocol-type-picker input:checked+span .game-icon{opacity:1}.protocol-type-picker input:focus-visible+span{outline:2px solid var(--shd-orange);outline-offset:2px}.protocol-level-preview{display:grid;grid-template-columns:minmax(220px,1fr);gap:3px;padding:8px 10px;border:1px solid var(--shd-border);background:#0d1011}.protocol-level-preview input{width:100%;margin:0;accent-color:var(--shd-orange)}.protocol-level-preview>div{display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:7px;color:#626a6c;font-size:var(--shd-type-telemetry)}.protocol-level-preview i{height:2px;background:var(--shd-orange)}.protocol-attribute-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:7px;margin-top:7px}.protocol-attribute-card{display:grid;gap:6px;padding:8px;border:1px solid var(--shd-border);background:#101314}.protocol-attribute-card header{min-height:38px;display:flex;align-items:center;gap:6px;border-bottom:1px solid #282e2f}.protocol-attribute-card header .game-icon{width:25px;height:25px}.protocol-attribute-card header div{display:grid;gap:2px}.protocol-attribute-card header span{color:#626a6c;font-size:var(--shd-type-telemetry);letter-spacing:.8px}.protocol-attribute-card header b{font-size:var(--shd-type-telemetry)}.protocol-attribute-card header>strong{margin-left:auto;color:var(--shd-orange);font-size:12px}.protocol-attribute-card .field{margin:0}.protocol-attribute-card>small{color:#626a6c;font-size:var(--shd-type-telemetry);text-align:right}.protocol-talent{margin-top:12px;padding:8px;border:1px solid #71552a;background:#0d1011}.protocol-talent-identity{display:grid;grid-template-columns:.7fr 1.3fr;gap:8px}.protocol-talent-identity textarea{min-height:65px}.protocol-upgrade-preview{display:grid;grid-template-columns:auto minmax(0,1fr);gap:8px;margin:8px 0}.diamond-control{display:flex;align-items:stretch;border:1px solid var(--shd-border)}.diamond-control button{min-width:42px;display:grid;place-items:center;padding:5px;border:0;border-left:1px solid var(--shd-border);background:#111415;color:#596164;cursor:pointer}.diamond-control button:first-child{border-left:0;font-size:var(--shd-type-telemetry);font-weight:900}.diamond-control button span{font-size:15px}.diamond-control button small{font-size:var(--shd-type-telemetry)}.diamond-control button:hover,.diamond-control button:focus-visible{background:#171b1c;color:var(--shd-text)}.diamond-control button:active{transform:translateY(1px);filter:brightness(.88)}.diamond-control button.active{background:#211a0e;color:var(--shd-orange);box-shadow:inset 0 -2px var(--shd-orange)}.protocol-upgrade-preview>div:last-child{padding:8px 10px;border:1px solid var(--shd-border);background:#101314}.protocol-upgrade-preview>div:last-child span{color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.protocol-upgrade-preview p{margin:6px 0 0;color:#d7d9d5;font-size:var(--shd-type-telemetry);line-height:1.45}.protocol-talent-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}.protocol-talent-effects{display:grid;border:1px solid var(--shd-border)}.protocol-talent-effects>div{display:grid;grid-template-columns:28px minmax(140px,1fr) 90px 80px 28px;gap:5px;align-items:center;padding:5px;border-top:1px solid #282e2f}.protocol-talent-effects>div:first-child{border-top:0}.protocol-talent-effects strong{display:grid;place-items:center;height:100%;background:#171b1c;color:var(--shd-orange);font-size:var(--shd-type-telemetry)}.protocol-talent-effects select,.protocol-talent-effects input{min-width:0;margin:0;padding:7px;font-size:var(--shd-type-telemetry)}.protocol-talent-effects span{color:#626a6c;font-size:var(--shd-type-telemetry);text-align:center}.protocol-talent-effects button{height:28px;border:1px solid #563734;background:#1d1212;color:var(--shd-danger);cursor:pointer}.protocol-talent-effects button:disabled{opacity:.3;cursor:not-allowed}.protocol-row{min-width:1050px;grid-template-columns:1.2fr .65fr .8fr .7fr 1.1fr .55fr .3fr}.protocol-type-label{display:flex!important;align-items:center;gap:5px}.protocol-type-label .game-icon{width:20px;height:20px}.mini-diamonds{color:var(--shd-orange);letter-spacing:1px}@media(max-width:1000px){.protocol-identity-grid{grid-template-columns:1fr 1fr}.protocol-type-picker{grid-column:1/3}.protocol-attribute-grid,.protocol-talent-grid{grid-template-columns:1fr}.protocol-upgrade-preview{grid-template-columns:1fr}}@media(max-width:650px){.protocol-identity-grid,.protocol-talent-identity{grid-template-columns:1fr}.protocol-type-picker{grid-column:auto}.protocol-type-picker b{font-size:var(--shd-type-telemetry)}.diamond-control{overflow-x:auto}.protocol-talent-effects>div{grid-template-columns:26px 1fr}.protocol-talent-effects input,.protocol-talent-effects span,.protocol-talent-effects button{grid-column:2}} -.protocol-profile-registry{margin-bottom:10px;border:1px solid var(--shd-border);background:#0d1011}.protocol-profile-registry>summary{margin:0;padding:9px 10px;cursor:pointer;list-style:none}.protocol-profile-registry>summary::-webkit-details-marker{display:none}.protocol-profile-registry>summary:hover,.protocol-profile-registry>summary:focus-visible{background:#171b1c}.protocol-profile-registry>summary:active{transform:translateY(1px);filter:brightness(.88)}.protocol-profile-registry>summary>div:last-child{display:flex;align-items:center;gap:10px}.protocol-profile-registry>summary i{color:var(--shd-orange);font-size:13px;font-style:normal;transition:transform .16s ease}.protocol-profile-registry[open]>summary{border-bottom:1px solid var(--shd-border);background:#131718}.protocol-profile-registry[open]>summary i{transform:rotate(45deg)}.protocol-profile-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:5px;padding:8px}.protocol-profile-grid>button{min-width:0;padding:7px;border:1px solid var(--shd-border);background:#111415;color:var(--shd-text-muted);text-align:left;cursor:pointer}.protocol-profile-grid>button:hover,.protocol-profile-grid>button:focus-visible{border-color:#8a672b;background:#171b1c}.protocol-profile-grid>button:active{transform:translateY(1px);filter:brightness(.88)}.protocol-profile-grid>button.configured{border-left:2px solid var(--shd-success)}.protocol-profile-grid header{display:flex;align-items:center;gap:6px;padding-bottom:6px;border-bottom:1px solid #292e2f}.protocol-profile-grid header .game-icon{width:23px;height:23px}.protocol-profile-grid header div{display:grid;gap:2px}.protocol-profile-grid header b{font-size:var(--shd-type-telemetry);text-transform:uppercase}.protocol-profile-grid header strong{color:#d7d9d5;font-size:var(--shd-type-telemetry)}.protocol-profile-grid header>span{margin-left:auto;color:#6b7274;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.5px}.protocol-profile-grid>button.configured header>span{color:var(--shd-success)}.protocol-profile-grid>button>div{display:grid;gap:4px;margin-top:6px}.protocol-profile-grid>button>div span{display:flex;justify-content:space-between;gap:6px}.protocol-profile-grid>button>div small{overflow:hidden;color:#626a6c;font-size:var(--shd-type-telemetry);text-overflow:ellipsis;white-space:nowrap}.protocol-profile-grid>button>div b{color:#b8bdbe;font-size:var(--shd-type-telemetry)}.protocol-profile-editor{border-top-color:var(--shd-success)}.shared-secondary-editor{display:grid;grid-template-columns:repeat(3,1fr);gap:7px}.selected-secondary-profile{margin:8px 0;border:1px solid #36563f;background:#101713}.selected-secondary-profile.unconfigured{border-color:#65403a;background:#1c1111}.selected-secondary-profile header{display:flex;justify-content:space-between;padding:7px 9px;border-bottom:1px solid currentColor;color:var(--shd-success);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.selected-secondary-profile.unconfigured header{color:var(--shd-danger)}.selected-secondary-profile>div{display:grid;grid-template-columns:repeat(3,1fr)}.selected-secondary-profile>div span{display:flex;justify-content:space-between;padding:8px 9px;border-left:1px solid #2b3d30}.selected-secondary-profile>div span:first-child{border-left:0}.selected-secondary-profile small{color:#6f7b72;font-size:var(--shd-type-telemetry)}.selected-secondary-profile b{font-size:var(--shd-type-telemetry)}.selected-secondary-profile p{margin:0;padding:10px;color:#a87870;font-size:var(--shd-type-telemetry)}.protocol-primary-editor{display:grid;grid-template-columns:1fr 180px 180px;gap:7px;margin-top:7px;padding:8px;border:1px solid var(--shd-border);background:#101314}.protocol-primary-editor .field{margin:0}.protocol-primary-editor>div{display:grid;align-content:center;gap:4px;padding:0 10px;border-left:2px solid var(--shd-orange)}.protocol-primary-editor>div span,.protocol-primary-editor>div small{color:#626a6c;font-size:var(--shd-type-telemetry)}.protocol-primary-editor>div b{color:var(--shd-orange);font-size:15px}.protocol-row{grid-template-columns:1.2fr .65fr .8fr 1fr 1.1fr .55fr .3fr}@media(max-width:1100px){.protocol-profile-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}@media(max-width:1000px){.protocol-profile-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.protocol-primary-editor{grid-template-columns:1fr 1fr}.protocol-primary-editor>div{grid-column:1/3}}@media(max-width:650px){.protocol-profile-grid,.selected-secondary-profile>div,.protocol-primary-editor,.shared-secondary-editor{grid-template-columns:1fr}.selected-secondary-profile>div span{border-left:0;border-top:1px solid #2b3d30}.protocol-primary-editor>div{grid-column:auto}} -.weapon-exotic-talent{margin:10px 0;padding:10px;border:1px solid #765426;border-top:2px solid var(--shd-orange);background:#151208}.exotic-talent-identity{grid-template-columns:minmax(180px,.65fr) minmax(320px,1.35fr)!important}.exotic-talent-identity textarea{min-height:64px}.exotic-talent-identity small{display:block;margin-top:5px;color:#69665e;font-size:var(--shd-type-telemetry)}.weapon-exotic-grid{display:grid;grid-template-columns:.8fr 1.2fr;gap:8px;margin-top:8px}.exotic-effect-list{display:grid;gap:5px}.exotic-effect-row{display:grid;grid-template-columns:28px minmax(150px,1fr) 85px 85px 28px;gap:5px;align-items:center}.exotic-effect-row>strong{height:32px;display:grid;place-items:center;background:#211a0e;color:var(--shd-orange);font-size:var(--shd-type-telemetry)}.exotic-effect-row select,.exotic-effect-row input{height:32px;min-width:0;margin:0;padding:0 7px;font-size:var(--shd-type-telemetry)}.exotic-effect-row button{height:32px;border:1px solid #563734;background:#1d1212;color:#da746c;cursor:pointer}.exotic-effect-row button:disabled{opacity:.35;cursor:not-allowed}@media(max-width:1050px){.weapon-exotic-grid{grid-template-columns:1fr}}@media(max-width:700px){.exotic-talent-identity{grid-template-columns:1fr!important}.exotic-effect-row{grid-template-columns:28px 1fr 1fr}.exotic-effect-row select{grid-column:2/4}.exotic-effect-row button{grid-column:3}} -.protocol-identity-grid{grid-template-columns:minmax(280px,1.35fr) minmax(420px,1fr);gap:12px}.protocol-classification-stack{display:grid;gap:7px}.protocol-quality-picker{min-width:0;margin:0;padding:0;border:0}.protocol-quality-picker legend{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:bold;letter-spacing:1px}.protocol-quality-picker>div{height:32px;display:grid;grid-template-columns:repeat(4,1fr);margin-top:5px;border:1px solid #363b3c;background:#0d0f10}.protocol-quality-picker label{min-width:0;border-left:1px solid var(--shd-border);cursor:pointer}.protocol-quality-picker label:first-child{border-left:0}.protocol-quality-picker input{position:absolute;opacity:0;pointer-events:none}.protocol-quality-picker span{height:100%;display:flex;align-items:center;justify-content:center;gap:4px}.protocol-quality-picker span i{width:5px;height:5px;background:currentColor}.protocol-quality-picker b{font-size:var(--shd-type-telemetry);text-transform:uppercase}.protocol-quality-picker label:hover span{background:#171b1c;filter:brightness(1.2)}.protocol-quality-picker label:active span{transform:translateY(1px);filter:brightness(.88)}.protocol-quality-picker input:checked+span{background:#171b1c;box-shadow:inset 0 -2px currentColor}.protocol-quality-picker input:focus-visible+span{outline:2px solid var(--shd-orange);outline-offset:2px}@media(max-width:1000px){.protocol-identity-grid{grid-template-columns:1fr}}@media(max-width:650px){.protocol-quality-picker b{font-size:var(--shd-type-telemetry)}} -.icon-editor-identity,.talent-icon-identity,.exotic-icon-identity{display:grid;grid-template-columns:var(--isac-icon-editor-width) minmax(0,1fr);gap:9px;align-items:start}.icon-editor-identity>.field{margin:0}.talent-icon-identity>.form-grid,.exotic-icon-identity>.form-grid{min-width:0}.icon-upload-field{width:var(--isac-icon-editor-width);max-width:100%;min-width:0;margin:0;padding:7px;border:1px solid var(--shd-border);background:#0d1011}.icon-upload-field legend{padding:0 5px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.icon-upload-preview{height:86px;display:grid;place-items:center;margin-bottom:6px;border:1px solid #292f30;background:#111415}.icon-upload-preview img{width:72px;height:72px;object-fit:contain}.icon-upload-preview>span{color:#596164;font-size:var(--shd-type-telemetry);letter-spacing:.8px}.icon-upload-field>label{min-height:30px;display:grid;place-items:center;border:1px solid #424849;background:#15191a;color:#d7d9d5;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.6px;cursor:pointer}.icon-upload-field>label:hover,.icon-upload-field>label:focus-within{border-color:var(--shd-orange);color:var(--shd-orange)}.icon-upload-field>label:active{transform:translateY(1px);filter:brightness(.88)}.icon-upload-field>label input[type=file]{position:absolute;width:1px;height:1px;opacity:0}.icon-upload-field>label.icon-remove{min-height:24px;display:flex;gap:5px;margin-top:5px;border-color:#563734;background:#1d1212;color:var(--shd-danger)}.icon-upload-field>label.icon-remove input{width:auto;margin:0;accent-color:var(--shd-danger)}.icon-upload-field>small{display:block;margin-top:5px;color:#596164;font-size:var(--shd-type-telemetry);line-height:1.4}.dashboard-editor input[name=name]{min-height:46px;padding:9px 11px;font-size:15px;font-weight:800;letter-spacing:.35px}.registry-icon-identity{display:flex!important;align-items:center;gap:7px;min-width:0}.registry-icon-identity>img,.registry-icon-identity>span{flex:0 0 30px;width:30px;height:30px;object-fit:contain}.registry-icon-identity>span{display:grid;place-items:center;border:1px solid #303536;color:#596164}.registry-icon-identity>div{display:grid;min-width:0}.registry-icon-identity>div strong,.registry-icon-identity>div small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}@media(max-width:700px){.icon-editor-identity,.talent-icon-identity,.exotic-icon-identity{grid-template-columns:1fr}.icon-upload-field{width:100%;max-width:none}.icon-upload-preview{height:70px}} -.brand-editor-body{display:grid;grid-template-columns:var(--isac-icon-editor-width) minmax(0,1fr);gap:10px;margin-top:8px;align-items:start}.brand-editor-body>div{min-width:0}.brand-set-editor .brand-name{max-width:none;margin:0 0 7px}.brand-set-editor .icon-upload-field{padding:6px}.brand-set-editor .icon-upload-preview{height:58px;margin-bottom:5px}.brand-set-editor .icon-upload-preview img{width:50px;height:50px}.brand-set-editor .icon-upload-field>label{min-height:25px}.brand-set-editor .icon-upload-field>label.icon-remove{min-height:22px}.brand-set-editor .effect-band{padding:7px}.brand-set-editor .form-actions{margin-top:10px;padding-top:10px}.brand-archive-action{min-height:40px;padding:0 13px}.brand-archive-action>b{font-size:var(--shd-type-telemetry);transform:rotate(90deg)}@media(max-width:850px){.brand-editor-body{grid-template-columns:1fr}.brand-set-editor .icon-upload-field{width:100%;display:grid;grid-template-columns:70px 1fr;gap:5px}.brand-set-editor .icon-upload-field legend,.brand-set-editor .icon-upload-field>small{grid-column:1/3}.brand-set-editor .icon-upload-preview{grid-row:1/3;height:55px;margin:0}.brand-set-editor .icon-upload-field>label{min-height:25px}}@media(max-width:560px){.brand-set-editor .icon-upload-field{grid-template-columns:1fr}.brand-set-editor .icon-upload-field legend,.brand-set-editor .icon-upload-field>small{grid-column:auto}.brand-set-editor .icon-upload-preview{grid-row:auto}.brand-archive-action{width:100%}} -.brand-archive-action.restore>b{transform:none}.moderation-action.restore{border:1px solid #3d6849;background:#111d15;color:var(--shd-success)}.moderation-action.restore:hover,.moderation-action.restore:focus-visible{border-color:#69b77d;background:#17271c;color:#a4e5b6} -.pending-reference,.dashboard-row.pending-record{border-color:color-mix(in srgb,var(--shd-orange) 58%,#303536)!important;background:color-mix(in srgb,var(--shd-orange) 7%,#121516)!important}.pending-reference-label,.state-dot.pending{color:var(--shd-orange);font-family:var(--font-display);font-size:.62rem;letter-spacing:.08em;white-space:nowrap}.state-dot.pending:before{background:var(--shd-orange)} -.data-access-banner{justify-content:space-between}.data-access-banner>div{display:grid;gap:3px}.data-access-banner form{margin:0}.mode-toggle{min-height:28px;display:inline-flex;align-items:center;padding:0 9px;border:1px solid #4b5152;background:#15191a;color:#c7cdce;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.65px;white-space:nowrap;cursor:pointer}.mode-toggle:hover,.mode-toggle:focus-visible{border-color:var(--shd-orange);color:var(--shd-orange)}.mode-toggle:active{transform:translateY(1px);filter:brightness(.86)} -.contributor-guidelines-toolbar{align-items:flex-end}.guideline-version{min-width:120px;display:grid;gap:4px;padding:8px 10px;border:1px solid var(--shd-border);background:#111415}.guideline-version span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:1px}.guideline-version strong{color:var(--shd-orange);font-size:var(--shd-type-telemetry)}.contributor-guidelines{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin:10px 0}.contributor-guidelines section{min-height:122px;display:grid;grid-template-columns:34px 1fr;border:1px solid var(--shd-border);background:#111415}.contributor-guidelines section>span{display:grid;place-items:start center;padding-top:11px;border-right:1px solid var(--shd-border);color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900}.contributor-guidelines section>div{padding:11px}.contributor-guidelines h3{margin:0 0 6px;font-size:var(--shd-type-telemetry);letter-spacing:.7px}.contributor-guidelines p{margin:0;color:#858d8f;font-size:var(--shd-type-telemetry);line-height:1.55}.contributor-acceptance{display:flex;align-items:flex-start;gap:9px;padding:11px;border:1px solid #6f511c;background:#19150e;cursor:pointer}.contributor-acceptance input{width:auto;margin:2px 0 0;accent-color:var(--shd-orange)}.contributor-acceptance span{display:grid;gap:4px}.contributor-acceptance b{color:#e3c487;font-size:var(--shd-type-telemetry);letter-spacing:.7px}.contributor-acceptance small{color:#917d59;font-size:var(--shd-type-telemetry)}.form-actions a.button{display:inline-flex;align-items:center;justify-content:center;text-decoration:none}@media(max-width:760px){.contributor-guidelines{grid-template-columns:1fr}.data-access-banner{align-items:flex-start;flex-direction:column}.mode-toggle{width:100%;justify-content:center}} -.command-status .data-mode-status{border-left:2px solid #667174}.command-status .data-mode-status strong{color:#b8c0c1;white-space:nowrap}.command-status .data-mode-status.contributor{border-left-color:var(--shd-orange)}.command-status .data-mode-status.contributor strong{color:#f2b64d}.command-status .data-mode-status.approver{border-left-color:#68a9c4}.command-status .data-mode-status.approver strong{color:#8ecce5}.command-status .data-mode-status.data-manager,.command-status .data-mode-status.admin{border-left-color:#75c08c}.command-status .data-mode-status.data-manager strong,.command-status .data-mode-status.admin strong{color:#8bd49f} -@media(max-width:900px){.admin-command{align-items:flex-start;gap:12px}.command-status{display:grid;grid-template-columns:repeat(2,minmax(105px,1fr))}}@media(max-width:560px){.admin-command{display:grid}.command-status{width:100%}.command-status>div{min-width:0}} -.data-readonly .module-toolbar button,.data-readonly .dashboard-row:not(.table-heading)>button,.data-readonly .dashboard-editor,.data-readonly form[method=post]{display:none!important} -.command-status .data-mode-status.oversight{border-left-color:#68a9c4}.command-status .data-mode-status.oversight strong{color:#8ecce5}.oversight-decision-tabs{grid-template-columns:repeat(3,1fr)}.proposal-correction-head{display:flex;align-items:center;justify-content:space-between;margin:10px 0 6px;padding:8px 9px;border:1px solid var(--shd-border);background:#0d1011}.proposal-correction-head>div{display:grid;gap:3px}.proposal-correction-head span{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.proposal-correction-head small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry)}.proposal-correction-head>b{color:var(--shd-orange);font-size:var(--shd-type-telemetry)}.proposal-correction-grid{max-height:340px;display:grid;grid-template-columns:1fr 1fr;gap:6px;overflow:auto;padding:7px;border:1px solid var(--shd-border);background:#0b0e0f}.proposal-correction-grid .field{margin:0}.proposal-correction-grid .field>span{display:flex;justify-content:space-between}.proposal-correction-grid .field>span small{color:#61696b;font-size:var(--shd-type-telemetry)}.proposal-correction-grid textarea{min-height:74px;resize:vertical}.immutable-proposal-file{display:grid;align-content:center;gap:4px;min-height:54px;padding:8px;border:1px dashed #394041;color:var(--shd-text-muted)}.immutable-proposal-file span,.immutable-proposal-file small{font-size:var(--shd-type-telemetry)}.immutable-proposal-file b{color:var(--shd-text);font-size:var(--shd-type-telemetry)}@media(max-width:700px){.oversight-decision-tabs,.proposal-correction-grid{grid-template-columns:1fr}} -.mobile-nav-toggle,.mobile-nav-layer{display:none}@media(max-width:900px){.site-header{position:relative;z-index:40;padding:0 16px}.site-header>.account,.site-header>.sign-in{display:none}.mobile-nav-toggle{width:52px;height:44px;display:grid;grid-template-columns:1fr;align-content:center;gap:4px;margin-left:auto;padding:6px 8px;border:1px solid var(--shd-border);background:#111415;color:var(--shd-text);cursor:pointer}.mobile-nav-toggle>span{width:20px;height:1px;margin-left:auto;background:var(--shd-orange);transition:transform .14s ease,opacity .14s ease}.mobile-nav-toggle>b{font-size:var(--shd-type-telemetry);letter-spacing:1px;text-align:right}.mobile-nav-toggle[aria-expanded=true]>span:nth-child(1){transform:translateY(5px) rotate(45deg)}.mobile-nav-toggle[aria-expanded=true]>span:nth-child(2){opacity:0}.mobile-nav-toggle[aria-expanded=true]>span:nth-child(3){transform:translateY(-5px) rotate(-45deg)}.mobile-nav-toggle:hover,.mobile-nav-toggle:focus-visible{border-color:var(--shd-orange);background:#171b1c}.mobile-nav-toggle:active{transform:translateY(1px);filter:brightness(.86)}.mobile-nav-layer{position:fixed;z-index:35;inset:76px 0 0;display:grid;grid-template-columns:1fr minmax(270px,82vw)}.mobile-nav-backdrop{grid-column:1;grid-row:1;width:100%;height:100%;padding:0;border:0;background:#050607c9;backdrop-filter:blur(2px);cursor:pointer}.mobile-navigation{grid-column:2;grid-row:1;width:100%;height:100%;display:flex;flex-direction:column;margin:0;overflow-y:auto;border-left:1px solid #4a5051;background:#0d1011;box-shadow:-18px 0 40px #000b}.mobile-nav-heading{min-height:61px;display:flex;align-items:center;justify-content:space-between;padding:11px 14px;border-bottom:1px solid var(--shd-border);background:#111415}.mobile-nav-heading>div{display:grid;gap:4px}.mobile-nav-heading span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:1.2px}.mobile-nav-heading strong{font-size:13px;letter-spacing:1px}.mobile-nav-heading>b{color:var(--shd-success);font-size:var(--shd-type-telemetry);letter-spacing:1px}.mobile-navigation>a{min-height:54px;display:grid;grid-template-columns:42px 1fr 20px;align-items:center;padding:0 13px 0 0;border-bottom:1px solid var(--shd-border-soft);color:#a1a8a9}.mobile-navigation>a small{height:100%;display:grid;place-items:center;background:#111415;color:#555d5f;font-size:var(--shd-type-telemetry);letter-spacing:.7px}.mobile-navigation>a span{padding-left:13px;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1px;text-transform:uppercase}.mobile-navigation>a>b{color:var(--shd-orange);font-size:18px}.mobile-navigation>a:hover,.mobile-navigation>a:focus-visible,.mobile-navigation>a.active{background:#171b1c;color:#fff}.mobile-navigation>a.active{box-shadow:inset 2px 0 var(--shd-orange)}.mobile-navigation>a.active small{background:#211a0e;color:var(--shd-orange)}.mobile-navigation>a:active{filter:brightness(.82)}.mobile-agent-state{display:grid;gap:4px;margin-top:auto;padding:14px;border-top:1px solid var(--shd-border)}.mobile-agent-state span{color:#747c7e;font-size:var(--shd-type-telemetry);letter-spacing:1px}.mobile-agent-state i{display:inline-block;width:5px;height:5px;border-radius:50%;background:var(--shd-success);box-shadow:0 0 6px var(--shd-success)}.mobile-agent-state strong{font-size:var(--shd-type-telemetry)}.mobile-navigation form{margin:0;padding:0 14px 14px}.mobile-sign-out{width:100%;min-height:40px;border:1px solid #503b37;background:#1b1211;color:var(--shd-danger);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1px;cursor:pointer}.mobile-sign-out:hover,.mobile-sign-out:focus-visible{border-color:var(--shd-danger);background:#251715}.mobile-sign-out:active{transform:translateY(1px);filter:brightness(.86)}}@media(max-width:430px){.mobile-nav-layer{grid-template-columns:36px 1fr}}@media(prefers-reduced-motion:reduce){.mobile-nav-toggle>span{transition:none}} -@media(max-width:700px){.module-toolbar{align-items:stretch;flex-direction:column;gap:11px;padding-bottom:11px}.module-toolbar>div:first-child{width:100%}.module-toolbar>button{width:100%;min-height:48px;justify-content:center}.module-toolbar>div:last-child:has(button){width:100%;display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:7px}.module-toolbar>div:last-child:has(button)>button{width:100%;min-height:48px;padding:0 14px;font-size:var(--shd-type-telemetry);letter-spacing:1px}.module-toolbar .primary,.module-toolbar .secondary{height:auto;min-height:48px}} +.brand-name { + display: block; + max-width: 420px; + margin: 10px 0; +} +.effect-editor { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} +.effect-band { + display: grid; + grid-template-columns: 58px minmax(0, 1fr) 100px; + align-items: end; + gap: 8px; + padding: 9px; + background: #101314; + border: 1px solid #343a3b; +} +.effect-band > strong { + align-self: stretch; + display: grid; + place-content: center; + text-align: center; + color: #f6a821; + font-size: 23px; + border-right: 1px solid #343a3b; +} +.effect-band > strong small { + display: block; + color: #707779; + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; +} +.effect-band .field { + margin: 0; +} +.split-actions { + justify-content: space-between; +} +.split-actions > div { + display: flex; + gap: 8px; +} +.brand-set-row { + grid-template-columns: 1.1fr 1.2fr 1.2fr 1.2fr 0.6fr 0.3fr; +} +.empty-dashboard { + min-height: 120px; + display: grid; + place-content: center; + text-align: center; + gap: 6px; + color: #626a6c; +} +.empty-dashboard strong { + font-size: var(--shd-type-telemetry); + letter-spacing: 1.2px; +} +.empty-dashboard span { + font-size: var(--shd-type-telemetry); +} +.gear-identity { + display: grid; + grid-template-columns: 2fr 1fr 0.65fr 0.65fr; + gap: 8px; +} +.gear-ranges { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} +.range-band { + grid-template-columns: 58px 1fr 1fr; +} +.effect-band.unavailable { + opacity: 0.4; +} +.selection-head { + display: flex; + justify-content: space-between; + align-items: end; + margin: 14px 0 6px; +} +.selection-head > div { + display: grid; +} +.selection-head span { + font-size: var(--shd-type-telemetry); + font-weight: bold; + letter-spacing: 1.2px; +} +.selection-head small { + font-size: var(--shd-type-telemetry); + color: #697174; + margin-top: 3px; +} +.selection-head b { + color: #f6a821; + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; +} +.selection-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + border: 1px solid #303536; +} +.selection-grid label { + min-height: 38px; + display: grid; + grid-template-columns: 20px 1fr auto; + align-items: center; + gap: 5px; + padding: 6px 8px; + border: 1px solid transparent; + border-right-color: #282d2e; + border-bottom-color: #282d2e; + cursor: pointer; + font-size: var(--shd-type-telemetry); +} +.selection-grid label:hover { + background: #171b1c; + border-color: #4b4f50; +} +.selection-grid input { + width: auto; + margin: 0; + accent-color: #f6a821; +} +.selection-grid small { + font-size: var(--shd-type-telemetry); + color: #646c6e; + letter-spacing: 0.8px; +} +.selection-grid input:checked ~ small { + color: #75c08c; +} +.gear-row { + min-width: 1050px; + grid-template-columns: 1.2fr 0.8fr 0.8fr 1.35fr 0.8fr 0.6fr 0.3fr; +} +.gear-row > span small { + font-size: var(--shd-type-telemetry); + color: #858b8d; +} +.unit-field { + min-width: 0; + margin: 8px 0; + padding: 0; + border: 0; +} +.unit-field legend { + font-size: var(--shd-type-telemetry); + font-weight: bold; + letter-spacing: 1px; + color: #858b8d; +} +.unit-picker { + display: grid; + grid-template-columns: repeat(5, 1fr); + height: 32px; + margin-top: 5px; + border: 1px solid #424849; +} +.unit-picker label { + display: grid; + place-items: center; + border-left: 1px solid #303536; + cursor: pointer; +} +.unit-picker label:first-child { + border-left: 0; +} +.unit-picker input { + position: absolute; + opacity: 0; + pointer-events: none; +} +.unit-picker span { + width: 100%; + height: 100%; + display: grid; + place-items: center; + color: #777f81; + font-size: var(--shd-type-telemetry); + font-weight: bold; +} +.unit-picker label:hover span { + background: #191d1e; + color: #ecebe5; +} +.unit-picker input:checked + span { + background: #2b2111; + color: #f6a821; + box-shadow: inset 0 -2px #f6a821; +} +.unit-picker input:focus-visible + span { + outline: 2px solid #f6a821; + outline-offset: 2px; +} +.template-field { + display: grid; + margin-top: 10px; +} +.template-field small { + margin-top: 5px; + color: #697174; + font-size: var(--shd-type-telemetry); +} +.tier-preview { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin: 8px 0 12px; +} +.tier-preview > div { + padding: 9px 11px; + border: 1px solid #343a3b; + background: #101314; +} +.tier-preview span, +.effect-heading { + color: #697174; + font-size: var(--shd-type-telemetry); + letter-spacing: 1.1px; +} +.tier-preview p { + min-height: 30px; + margin: 5px 0 0; + color: #d9d9d3; + font-size: var(--shd-type-telemetry); + line-height: 1.45; +} +.shared-trigger-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + max-height: 170px; +} +.shared-effects { + border: 1px solid #303536; +} +.shared-effect { + display: grid; + grid-template-columns: minmax(180px, 1fr) 120px 120px 30px; + gap: 6px; + align-items: center; + padding: 6px; + border-top: 1px solid #292f30; +} +.shared-effect:first-child { + border-top: 0; +} +.shared-effect select, +.shared-effect input { + min-width: 0; + margin: 0; + padding: 7px; + font-size: var(--shd-type-telemetry); +} +.shared-effect button { + height: 30px; + border: 1px solid #563734; + background: #1d1212; + color: #da746c; +} +.effect-heading { + min-height: 28px; + background: #0d1011; +} +.effect-heading span:last-child { + display: none; +} +.talent-row { + grid-template-columns: + minmax(150px, 0.8fr) minmax(105px, 0.45fr) minmax(260px, 1.35fr) + minmax(260px, 1.35fr) 80px 45px; + min-width: 980px; +} +.talent-row:not(.table-heading) { + align-items: start; + padding-top: 10px; + padding-bottom: 10px; +} +.talent-description { + margin: 0; + color: #b7bcba; + font-size: var(--shd-type-telemetry); + line-height: 1.45; + white-space: normal; +} +.talent-description.unconfigured { + color: #df8069; + font-size: var(--shd-type-telemetry); + letter-spacing: 0.8px; +} +.talent-row .state-dot { + margin-top: 2px; +} +.talent-row > button { + margin-top: -3px; +} +.talent-mechanics-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + margin-top: 8px; + padding: 8px; + border: 1px solid var(--shd-border); + background: #0d1011; +} +.talent-mechanics-grid .field { + margin: 0; +} +.talent-template-note { + margin: 6px 0 0; + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + letter-spacing: 0.55px; +} +.talent-cooldown { + display: block !important; + margin-top: 3px; + color: var(--shd-orange) !important; + font-style: normal; + font-size: var(--shd-type-telemetry) !important; + letter-spacing: 0.55px; +} +.summary-talent .talent-cooldown { + margin-top: 5px; +} +@media (max-width: 700px) { + .talent-mechanics-grid { + grid-template-columns: 1fr; + } +} +.talent-kpis { + grid-template-columns: repeat(6, minmax(105px, 1fr)); +} +@media (max-width: 1200px) { + .talent-kpis { + grid-template-columns: repeat(3, minmax(105px, 1fr)); + } +} +@media (max-width: 650px) { + .talent-kpis { + grid-template-columns: repeat(2, minmax(105px, 1fr)); + } +} +.section-label { + margin-top: 10px; + border-top: 1px solid #2c3132; +} +.user-search { + display: flex; + gap: 6px; +} +.user-search input { + height: 40px; + min-width: 240px; + margin: 0; + padding: 0 10px; + font-size: var(--shd-type-telemetry); +} +.user-search button { + height: 40px; +} +.user-row { + min-width: 1160px; + grid-template-columns: 1.1fr 0.65fr 1.1fr 1fr 1fr 0.9fr 0.3fr; +} +.auth-method-summary { + display: flex; + flex-wrap: wrap; + gap: 3px; +} +.auth-method-summary b { + padding: 3px 4px; + border: 1px solid #3a4840; + color: #75c08c; + font-size: var(--shd-type-telemetry); + letter-spacing: 0.5px; +} +.user-row > span > small { + display: block; + margin-bottom: 3px; + color: #f6a821; + font-size: var(--shd-type-telemetry); +} +.restricted { + color: #df8069 !important; +} +.row-action { + justify-self: end; + color: #f6a821; + font-size: var(--shd-type-telemetry); + font-weight: bold; +} +.button-link { + display: grid; + place-items: center; + height: 40px; + padding: 0 14px; + border: 1px solid #424849; + color: #d7d9d5; + font-size: var(--shd-type-telemetry); + font-weight: bold; +} +.metric-date { + font-size: var(--shd-type-telemetry) !important; +} +.user-console-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) 380px; + gap: 10px; +} +.user-console-grid .dashboard-editor { + margin-top: 0; +} +.permission-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} +.moderation-panel .field { + display: block; +} +.console-note { + padding: 20px; + color: #747c7e; + font-size: var(--shd-type-telemetry); +} +.isac-auth-method-list { + display: grid; + border: 1px solid #303536; +} +.isac-auth-method { + position: relative; + min-height: 45px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + gap: 6px; + padding: 7px 8px; + border-top: 1px solid #292e2f; + background: #0d1011; + overflow: hidden; +} +.isac-auth-method:first-child { + border-top: 0; +} +.isac-auth-method > div { + display: grid; + gap: 3px; +} +.isac-auth-method b { + font-size: var(--shd-type-telemetry); +} +.isac-auth-method small { + color: #697174; + font-size: var(--shd-type-telemetry); + line-height: 1.35; +} +.isac-auth-method > span { + color: #626a6c; + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.8px; +} +.isac-auth-method > span.linked { + color: #75c08c; +} +.isac-auth-method.planned { + opacity: 0.55; +} +.isac-auth-method form, +.isac-auth-method fieldset { + height: 100%; +} +.isac-auth-method button { + min-height: 28px; + height: 100%; + padding: 0 7px; + font-size: var(--shd-type-telemetry); +} +.isac-method-confirm { + position: absolute; + z-index: 2; + inset: 0; + display: grid !important; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + padding: 6px 8px; + background: #211414; + border: 1px solid #9a5043; +} +.isac-method-confirm > div:first-child { + display: grid; + gap: 3px; +} +.isac-method-confirm > div:first-child b { + color: var(--shd-danger); +} +.isac-method-confirm > div:last-child { + height: 30px; + display: grid; + grid-template-columns: 60px 96px; + gap: 5px; +} +.isac-method-confirm .secondary { + border: 1px solid #565d5f; + background: #101314; + color: #d7d9d5; +} +.isac-method-confirm .primary { + background: var(--shd-orange); + color: #111; +} +.user-history-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} +.user-history-grid > section { + min-width: 0; + border: 1px solid #303536; + background: #111415; +} +.history-head { + display: flex; + justify-content: space-between; + padding: 10px; + background: #0d1011; + color: #727a7c; + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; +} +.history-list { + max-height: 300px; + overflow: auto; +} +.history-list > div { + display: grid; + grid-template-columns: 1fr auto auto; + gap: 8px; + padding: 9px 10px; + border-top: 1px solid #292e2f; + font-size: var(--shd-type-telemetry); +} +.history-list span { + color: #767e80; +} +.history-list b { + font-size: var(--shd-type-telemetry); +} +.history-list > p { + padding: 28px; + text-align: center; + color: #626a6c; + font-size: var(--shd-type-telemetry); +} +.comment-history > div { + grid-template-columns: 1fr; +} +.comment-history span { + white-space: normal; + line-height: 1.4; +} +@media (max-width: 1000px) { + .user-console-grid, + .user-history-grid { + grid-template-columns: 1fr; + } + .permission-grid { + grid-template-columns: 1fr 1fr; + } +} +@media (max-width: 560px) { + .user-search { + width: 100%; + } + .user-search input { + min-width: 0; + flex: 1; + } + .permission-grid { + grid-template-columns: 1fr; + } +} +.isac-operation-tabs { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin: 10px 0 0; + border: 1px solid var(--shd-border); + background: #0d1011; +} +.isac-operation-tabs button { + height: 40px; + border: 0; + border-left: 1px solid var(--shd-border); + border-bottom: 2px solid transparent; + background: transparent; + color: var(--shd-text-dim); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 1px; + cursor: pointer; +} +.isac-operation-tabs button:first-child { + border-left: 0; +} +.isac-operation-tabs button b { + margin-left: 5px; + color: var(--shd-success); +} +.isac-operation-tabs button:hover, +.isac-operation-tabs button:focus-visible { + background: #171b1c; + color: var(--shd-text); +} +.isac-operation-tabs button.active { + border-bottom-color: var(--shd-orange); + background: #211a0e; + color: var(--shd-orange); +} +.isac-operation-tabs button:focus-visible { + position: relative; + z-index: 1; + outline: 2px solid var(--shd-orange); + outline-offset: 2px; +} +.user-console-grid { + grid-template-columns: minmax(0, 1fr); +} +.isac-panel-hidden { + display: none !important; +} +.user-history-registries { + display: grid; + gap: 10px; + margin-top: 10px; +} +.history-registry { + min-width: 0; + border: 1px solid var(--shd-border); + background: var(--shd-surface); +} +.history-filter { + display: grid; + grid-template-columns: minmax(180px, 1fr) 170px 80px; + gap: 6px; + padding: 8px; + border-bottom: 1px solid var(--shd-border-soft); +} +.history-filter input, +.history-filter select { + height: 34px; + margin: 0; + padding: 0 9px; + font-size: var(--shd-type-telemetry); +} +.history-filter button { + height: 34px; + padding: 0 9px; + font-size: var(--shd-type-telemetry); +} +.history-registry:last-child .history-filter { + grid-template-columns: minmax(180px, 1fr) 80px; +} +.history-registry .registry-pagination { + margin: 0; + border-width: 0 0 1px; +} +.history-registry .history-list { + max-height: none; +} +.history-registry .comment-history > div { + grid-template-columns: minmax(140px, 0.5fr) minmax(260px, 1.5fr) auto; +} +.history-registry .comment-history span { + white-space: normal; + line-height: 1.4; +} +@media (max-width: 650px) { + .isac-operation-tabs, + .history-filter, + .history-registry:last-child .history-filter { + grid-template-columns: 1fr; + } + .isac-operation-tabs button { + border-left: 0; + border-top: 1px solid var(--shd-border); + } + .isac-operation-tabs button:first-child { + border-top: 0; + } +} +.isac-modal-backdrop { + position: fixed; + z-index: 50; + inset: 0; + display: grid; + place-items: center; + padding: 20px; + background: #050607d9; + backdrop-filter: blur(3px); +} +.isac-confirm-modal { + width: min(680px, 100%); + max-height: calc(100vh - 40px); + overflow: auto; + padding: 16px; + border: 1px solid #5a6061; + border-top: 2px solid var(--shd-orange); + background: #131617; + box-shadow: 0 24px 80px #000; +} +.access-confirm-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} +.access-confirm-grid > div { + min-height: 120px; + align-content: start; + display: grid; + gap: 5px; + padding: 10px; + border: 1px solid var(--shd-border); + background: #0d1011; +} +.access-confirm-grid > div > span { + margin-bottom: 3px; + color: var(--shd-text-dim); + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; +} +.access-confirm-grid b { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + padding: 6px 7px; + border-left: 2px solid #596164; + background: #151819; + color: #aeb3b4; + font-size: var(--shd-type-telemetry); +} +.access-confirm-grid b span { + color: inherit; +} +.access-confirm-grid b em { + font-style: normal; + color: #747c7e; + font-size: var(--shd-type-telemetry); + letter-spacing: 0.8px; +} +.access-confirm-grid b.access-add { + border-left-color: var(--shd-success); + background: #101713; + color: #9bd4ac; +} +.access-confirm-grid b.access-add em { + color: var(--shd-success); +} +.access-confirm-grid b.access-remove { + border-left-color: var(--shd-danger); + background: #211414; + color: #dca096; + text-decoration: line-through; +} +.access-confirm-grid b.access-remove em { + color: var(--shd-danger); + text-decoration: none; +} +.access-confirm-grid small { + color: var(--shd-text-dim); + font-size: var(--shd-type-telemetry); +} +@media (max-width: 560px) { + .access-confirm-grid { + grid-template-columns: 1fr; + } +} +.login-investigation-list > div { + grid-template-columns: 1fr 1fr 1.2fr auto; + align-items: center; +} +.login-investigation-list > div > div { + display: grid; + gap: 4px; +} +.login-investigation-list small { + color: var(--shd-text-dim); + font-size: var(--shd-type-telemetry); + letter-spacing: 0.4px; +} +.deletion-purge-option { + display: grid !important; + grid-template-columns: 20px 1fr; + align-items: start; + gap: 8px; + margin: 14px 0 !important; + padding: 10px; + border: 1px solid #70423b; + background: #211414; +} +.deletion-purge-option input { + width: auto; + margin: 2px 0 0; + accent-color: var(--shd-danger); +} +.deletion-purge-option span { + display: grid; + gap: 4px; +} +.deletion-purge-option b { + color: var(--shd-danger); + font-size: var(--shd-type-telemetry); +} +.deletion-purge-option small { + margin: 0; + color: #b18c85; + font-size: var(--shd-type-telemetry); +} +.site-footer { + position: relative; + z-index: 2; + min-height: 150px; + display: grid; + grid-template-columns: 1fr auto minmax(300px, 0.9fr); + align-items: center; + gap: 28px; + padding: 28px max(24px, 5vw); + border-top: 1px solid var(--shd-border); + background: #080a0b; +} +.footer-brand { + width: max-content; +} +.site-footer > div > p { + margin: 8px 0 0; + color: var(--shd-text-dim); + font: 11px var(--shd-font-editorial); +} +.footer-links { + display: grid; + gap: 8px; +} +.footer-links a { + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 1px; +} +.fan-disclaimer { + min-width: 0; + margin: 0; + color: #555d5f; + font-size: var(--shd-type-telemetry); + line-height: 1.6; + letter-spacing: 0.55px; + overflow-wrap: anywhere; +} +.legal-page { + max-width: 1320px; + margin: auto; + padding: 55px clamp(18px, 5vw, 72px) 90px; +} +.legal-hero { + padding-bottom: 30px; + border-bottom: 1px solid var(--shd-border); +} +.legal-hero h1 { + margin: 18px 0 14px; + font-size: clamp(48px, 7vw, 82px); + line-height: 0.9; + letter-spacing: -3px; +} +.legal-hero h1 em { + color: var(--shd-orange); + font-style: normal; +} +.legal-hero > p:last-child { + max-width: 720px; + color: var(--shd-text-muted); + font: 15px/1.6 var(--shd-font-editorial); +} +.legal-layout { + display: grid; + grid-template-columns: 210px minmax(0, 1fr); + gap: 28px; + margin-top: 24px; +} +.legal-index { + position: sticky; + top: 95px; + height: max-content; + display: grid; + margin: 0; + border: 1px solid var(--shd-border); +} +.legal-index a { + padding: 11px; + border-top: 1px solid var(--shd-border-soft); + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.8px; +} +.legal-index a:first-child { + border-top: 0; +} +.legal-index a:hover { + color: var(--shd-orange); + background: #171b1c; +} +.legal-document { + display: grid; +} +.legal-document section { + scroll-margin-top: 90px; + display: grid; + grid-template-columns: 42px 1fr; + gap: 14px; + padding: 24px 0; + border-top: 1px solid var(--shd-border); +} +.legal-document section:first-child { + border-top: 0; + padding-top: 0; +} +.legal-document section > span { + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); + font-weight: 900; +} +.legal-document h2 { + font-size: 18px; + letter-spacing: 0.6px; +} +.legal-document p { + color: #a1a7a8; + font: 13px/1.7 var(--shd-font-editorial); +} +.legal-document a { + color: var(--shd-orange); +} +@media (max-width: 1100px) { + .site-footer { + grid-template-columns: 1fr; + } + .legal-layout { + grid-template-columns: 1fr; + } + .legal-index { + position: static; + grid-template-columns: 1fr 1fr; + } + .login-investigation-list > div { + grid-template-columns: 1fr 1fr; + } +} +@media (max-width: 500px) { + .legal-index, + .login-investigation-list > div { + grid-template-columns: 1fr; + } +} +.legal-primary { + width: max-content; + color: #111 !important; + margin-top: 8px; +} +.contact-page { + width: 100%; + max-width: 1500px; + margin: auto; + padding: 34px clamp(18px, 4vw, 58px) 52px; +} +.contact-hero { + position: relative; + height: auto; + min-height: 230px; + display: grid; + grid-template-columns: minmax(0, 1fr) 310px; + align-items: center; + gap: 40px; + padding: 18px 0 30px; + border-bottom: 1px solid var(--shd-border); + background: transparent; +} +.contact-hero > div { + min-width: 0; +} +.contact-hero h1 { + margin: 18px 0 14px; + font-size: clamp(52px, 7vw, 88px); + line-height: 0.85; + letter-spacing: -4px; +} +.contact-hero h1 em { + color: var(--shd-orange); + font-style: normal; +} +.contact-hero > div > p:last-child { + max-width: 690px; + margin-bottom: 0; + color: var(--shd-text-muted); + font: 15px/1.6 var(--shd-font-editorial); +} +.discord-contact { + min-height: 82px; + display: grid; + grid-template-columns: 1fr auto; + align-content: center; + gap: 4px 10px; + padding: 15px 17px; + border: 1px solid #6571dc; + background: #171a32; + color: #aeb6ff; +} +.discord-contact span { + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 1.2px; +} +.discord-contact strong { + font-size: var(--shd-type-telemetry); + letter-spacing: 0.8px; +} +.discord-contact b { + grid-column: 2; + grid-row: 1/3; + align-self: center; + font-size: 18px; +} +.discord-contact:hover, +.discord-contact:focus-visible { + border-color: #929bff; + background: #202446; + color: #fff; +} +.contact-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 320px; + align-items: start; + gap: 12px; + margin-top: 12px; +} +.contact-console, +.contact-guidance { + border: 1px solid var(--shd-border); + background: var(--shd-surface); +} +.contact-console { + padding: 16px; +} +.contact-console-head { + min-height: 45px; + display: flex; + align-items: start; + justify-content: space-between; + padding-bottom: 10px; + margin-bottom: 13px; + border-bottom: 1px solid var(--shd-border); +} +.contact-console-head > div { + display: grid; + gap: 4px; +} +.contact-console-head span { + color: var(--shd-text-dim); + font-size: var(--shd-type-telemetry); + letter-spacing: 1.2px; +} +.contact-console-head strong { + font-size: 13px; +} +.contact-console-head > b { + color: var(--shd-success); + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; +} +.contact-console .segmented-choice > div { + height: 40px; +} +.contact-console .field { + margin: 10px 0; +} +.contact-console textarea { + line-height: 1.5; +} +.contact-identity { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 9px; +} +.contact-honeypot { + position: absolute; + left: -10000px; + width: 1px; + height: 1px; + overflow: hidden; +} +.dmca-contact-fields { + margin-top: 15px; + padding: 12px; + border: 1px solid #74552a; + border-left: 2px solid var(--quality-high-end); + background: #131108; +} +.dmca-contact-fields > header { + height: auto; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 0 9px; + border-bottom: 1px solid #493a22; + background: transparent; +} +.dmca-contact-fields > header > div { + display: grid; + gap: 4px; +} +.dmca-contact-fields > header span { + color: #82755f; + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; +} +.dmca-contact-fields > header strong { + color: var(--quality-high-end); + font-size: var(--shd-type-telemetry); +} +.dmca-contact-fields > header a { + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); + font-weight: 900; +} +.dmca-contact-fields > p { + margin: 10px 0; + color: #9b927f; + font: var(--shd-type-supporting)/1.55 var(--shd-font-editorial); +} +.legal-attestation { + min-height: 46px; + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + gap: 9px; + margin-top: 7px; + padding: 8px; + border: 1px solid #3c382e; + background: #0d0f0d; + color: #b5b0a3; + font-size: var(--shd-type-telemetry); + line-height: 1.45; + cursor: pointer; +} +.legal-attestation input { + width: auto; + margin: 0; + accent-color: var(--shd-orange); +} +.legal-attestation:has(input:checked) { + border-color: #7c5c29; + background: #1b170d; +} +.contact-guidance { + height: max-content; + display: grid; +} +.contact-guidance > div { + position: relative; + min-height: 126px; + padding: 16px 16px 14px 50px; + border-top: 1px solid var(--shd-border); +} +.contact-guidance > div:first-child { + border-top: 0; +} +.contact-guidance > div > span { + position: absolute; + left: 15px; + top: 17px; + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); + font-weight: 900; +} +.contact-guidance h2 { + font-size: 13px; + letter-spacing: 0.8px; +} +.contact-guidance p { + margin: 0; + color: var(--shd-text-muted); + font: var(--shd-type-supporting)/1.55 var(--shd-font-editorial); +} +.contact-guidance a { + display: inline-block; + margin-top: 10px; + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); + font-weight: 900; +} +.contact-guidance > small { + padding: 14px; + border-top: 1px solid var(--shd-border); + color: var(--shd-danger); + font-size: var(--shd-type-telemetry); + line-height: 1.5; + letter-spacing: 0.6px; +} +.contact-success { + min-height: 300px; + display: flex; + align-items: center; + justify-content: center; + gap: 16px; + text-align: left; +} +.contact-success > span { + width: 48px; + height: 48px; + display: grid; + place-items: center; + border: 1px solid var(--shd-success); + color: var(--shd-success); + font-size: 18px; +} +.contact-success strong { + font-size: 13px; + letter-spacing: 1px; +} +.contact-success p { + max-width: 430px; + margin: 7px 0 0; + color: var(--shd-text-muted); + font: 13px/1.55 var(--shd-font-editorial); +} +.contact-message-page { + width: 100%; + max-width: 1200px; + min-height: calc(100vh - var(--shd-header-height)); + margin: auto; + padding: 36px clamp(18px, 4vw, 55px) 70px; +} +.contact-back { + display: inline-block; + margin-bottom: 12px; + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.8px; +} +.contact-message { + border: 1px solid var(--shd-border); + border-top: 2px solid var(--shd-orange); + background: var(--shd-surface); +} +.contact-message > header { + position: static; + height: 62px; + display: flex; + justify-content: space-between; + padding: 0 17px; + border-bottom: 1px solid var(--shd-border); + background: #0d1011; +} +.contact-message > header > div { + display: grid; + gap: 4px; +} +.contact-message > header span { + color: var(--shd-text-dim); + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; +} +.contact-message > header strong { + font-size: var(--shd-type-telemetry); +} +.contact-message > header time { + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); +} +.contact-message-title { + padding: 28px clamp(20px, 5vw, 55px); + border-bottom: 1px solid var(--shd-border); +} +.contact-message-title h1 { + margin: 12px 0; + font-size: clamp(30px, 5vw, 48px); +} +.contact-message-title > p:last-child { + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + letter-spacing: 0.8px; +} +.contact-message-title a, +.contact-message section a { + color: var(--shd-orange); +} +.contact-message > section { + padding: 24px clamp(20px, 5vw, 55px); + border-bottom: 1px solid var(--shd-border); +} +.contact-message > section h2 { + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; +} +.contact-message > section > p { + margin: 12px 0 0; + color: #c0c4c5; + font: 14px/1.7 var(--shd-font-editorial); + white-space: pre-wrap; +} +.contact-dmca-record dl { + display: grid; + gap: 10px; +} +.contact-dmca-record dl > div { + display: grid; + grid-template-columns: 160px 1fr; + gap: 12px; + padding-top: 10px; + border-top: 1px solid var(--shd-border-soft); +} +.contact-dmca-record dt { + color: var(--shd-text-dim); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.8px; +} +.contact-dmca-record dd { + margin: 0; + color: #c0c4c5; + font: 12px/1.6 var(--shd-font-editorial); + white-space: pre-wrap; +} +.contact-message > footer { + display: flex; + justify-content: flex-end; + padding: 14px; +} +.contact-message > footer .primary { + padding: 13px 16px; +} +@media (max-width: 900px) { + .contact-hero, + .contact-layout { + grid-template-columns: 1fr; + } + .contact-hero { + min-height: 0; + align-items: start; + gap: 20px; + padding-top: 8px; + } + .discord-contact { + width: 100%; + } +} +@media (max-width: 600px) { + .contact-page { + padding-top: 24px; + padding-bottom: 36px; + } + .contact-hero h1 { + letter-spacing: -2.5px; + } + .contact-identity { + grid-template-columns: 1fr; + } + .contact-console .segmented-choice > div { + height: auto; + grid-template-columns: 1fr; + } + .contact-console .segmented-choice span { + min-height: 44px; + } + .dmca-contact-fields > header { + align-items: flex-start; + flex-direction: column; + gap: 8px; + } + .contact-dmca-record dl > div { + grid-template-columns: 1fr; + } + .contact-message > footer .primary { + width: 100%; + } +} +@media (max-width: 1100px) { + .effect-editor { + grid-template-columns: 1fr; + } + .selection-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} +@media (max-width: 900px) { + .gear-identity, + .gear-ranges { + grid-template-columns: 1fr 1fr; + } +} +@media (max-width: 560px) { + .effect-band { + grid-template-columns: 48px 1fr; + } + .effect-band .field:last-child { + grid-column: 2; + } + .split-actions { + align-items: stretch; + } + .split-actions, + .split-actions > div { + width: 100%; + } + .split-actions > div:last-child button { + flex: 1; + } + .gear-identity, + .gear-ranges, + .selection-grid { + grid-template-columns: 1fr; + } + .range-band { + grid-template-columns: 48px 1fr; + } + .range-band .field:last-child { + grid-column: 2; + } +} +.game-icon { + display: block; + width: 24px; + height: 24px; + object-fit: contain; +} +.weapon-editor { + border-top-color: #d07a2c; +} +.weapon-identity-grid { + display: grid; + grid-template-columns: 1.6fr repeat(5, minmax(0, 1fr)); + gap: 8px; +} +.weapon-fixed-grid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 8px; +} +.damage-type-picker { + min-width: 0; + margin: 0; + padding: 0; + border: 0; +} +.damage-type-picker legend { + color: #858b8d; + font-size: var(--shd-type-telemetry); + font-weight: bold; + letter-spacing: 1px; +} +.damage-type-picker > div { + height: 32px; + display: grid; + grid-template-columns: repeat(3, 1fr); + margin-top: 5px; + border: 1px solid #363b3c; + background: #0d0f10; +} +.damage-type-picker label { + min-width: 0; + border-left: 1px solid #303536; + cursor: pointer; +} +.damage-type-picker label:first-child { + border-left: 0; +} +.damage-type-picker input { + position: absolute; + opacity: 0; + pointer-events: none; +} +.damage-type-picker span { + height: 100%; + display: flex; + align-items: center; + justify-content: center; + gap: 3px; + color: #737a7c; +} +.damage-type-picker .game-icon { + width: 17px; + height: 17px; + opacity: 0.62; +} +.damage-type-picker b { + overflow: hidden; + font-size: var(--shd-type-telemetry); + letter-spacing: 0.25px; + text-overflow: ellipsis; +} +.damage-type-picker label:hover span { + background: #171b1c; + color: #ecebe5; +} +.damage-type-picker label:active span { + transform: translateY(1px); + filter: brightness(0.88); +} +.damage-type-picker input:checked + span { + background: #211a0e; + color: #f6a821; + box-shadow: inset 0 -2px #f6a821; +} +.damage-type-picker input:checked + span .game-icon { + opacity: 1; +} +.damage-type-picker input:focus-visible + span { + outline: 2px solid #f6a821; + outline-offset: 2px; +} +.exotic-toggle > span { + min-height: 32px; + margin-top: 5px; + padding: 0 9px; + display: flex; + align-items: center; + gap: 7px; + border: 1px solid #363b3c; + background: #0d0f10; + color: #d3d5d1; + font-size: var(--shd-type-telemetry); +} +.exotic-toggle input { + width: auto; + margin: 0; + accent-color: #f6a821; +} +.weapon-tier-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + margin-top: 9px; +} +.weapon-tier-card { + min-width: 0; + margin: 0; + padding: 7px; + border: 1px solid #3b4142; + background: #101314; +} +.weapon-tier-card legend { + padding: 0 7px; + color: #f6a821; + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 1.2px; +} +.weapon-range-row { + display: grid; + grid-template-columns: 65px 1fr 1fr; + gap: 5px; + align-items: end; + padding: 5px; + border-top: 1px solid #282e2f; +} +.weapon-range-row:first-of-type { + border-top: 0; +} +.weapon-range-row > strong { + align-self: center; + display: flex; + align-items: center; + gap: 4px; + color: #d7d9d5; + font-size: var(--shd-type-telemetry); +} +.weapon-range-row > strong .game-icon { + width: 20px; + height: 20px; +} +.weapon-range-row > strong small { + display: block; + color: #596164; + font-size: var(--shd-type-telemetry); + letter-spacing: 0.5px; +} +.weapon-range-row label { + color: #737a7c; + font-size: var(--shd-type-telemetry); + letter-spacing: 0.7px; +} +.weapon-range-row input { + margin-top: 3px; + padding: 7px; + font-size: var(--shd-type-telemetry); +} +.weapon-talent-pools { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 9px; +} +.compact-selection { + grid-template-columns: repeat(2, minmax(0, 1fr)); + max-height: 210px; + overflow: auto; +} +.weapon-base-bonuses, +.weapon-augment-effects { + display: grid; + gap: 5px; + margin-top: 7px; +} +.base-bonus-row, +.augment-effect { + display: grid; + grid-template-columns: 35px minmax(180px, 1fr) 130px 30px; + gap: 6px; + align-items: center; + padding: 5px; + border: 1px solid #303536; + background: #101314; +} +.base-bonus-row > strong, +.augment-effect > strong { + height: 100%; + display: grid; + place-items: center; + background: #171b1c; + color: #f6a821; + font-size: var(--shd-type-telemetry); +} +.base-bonus-row .field, +.augment-effect .field { + margin: 0; +} +.base-bonus-row button, +.augment-effect button { + height: 30px; + border: 1px solid #563734; + background: #1d1212; + color: #da746c; + cursor: pointer; +} +.base-bonus-row button:disabled { + opacity: 0.3; + cursor: not-allowed; +} +.augment-effect:not(:has(> button)) { + grid-template-columns: 35px minmax(180px, 1fr) 130px; +} +.augment-sequence { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 5px; + margin-top: 7px; +} +.augment-sequence > div { + min-height: 42px; + display: grid; + place-items: center; + padding: 5px; + border: 1px solid #303536; + background: #0d1011; +} +.augment-sequence b { + color: #f6a821; + font-size: var(--shd-type-telemetry); +} +.augment-sequence span { + color: #727a7c; + font-size: var(--shd-type-telemetry); +} +.augment-sequence .exotic-step { + border-color: #875d25; + background: #211a0e; +} +.weapon-row { + min-width: 1180px; + grid-template-columns: 1.3fr 0.7fr 1.2fr 0.6fr 0.6fr 0.5fr 0.5fr 0.3fr; +} +.weapon-row > span small { + color: #596164; +} +.weapon-damage-label { + display: flex !important; + align-items: center; + gap: 4px; +} +.weapon-damage-label .game-icon { + width: 16px; + height: 16px; +} +.weapon-dashboard-table { + margin-top: 10px; +} +.assignment-picker { + border: 1px solid #303536; + background: #0d1011; +} +.assignment-selected { + display: grid; + grid-template-columns: 70px 1fr; + min-height: 45px; + border-bottom: 1px solid #303536; +} +.assignment-summary { + display: grid; + place-items: center; + background: #15191a; +} +.assignment-summary span { + color: #687073; + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; +} +.assignment-summary b { + color: #f6a821; + font-size: 14px; +} +.assignment-chips { + display: flex; + align-items: center; + gap: 5px; + overflow: auto; + padding: 6px; +} +.assignment-chips > button { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 7px; + padding: 6px 8px; + border: 1px solid #6b5128; + background: #211a0e; + color: #e2cda7; + font-size: var(--shd-type-telemetry); + cursor: pointer; +} +.assignment-chips > button:hover { + border-color: #f6a821; + color: #f6a821; +} +.assignment-chips > button:active { + transform: translateY(1px); +} +.assignment-chips small { + color: #596164; + font-size: var(--shd-type-telemetry); +} +.assignment-search { + display: block; + padding: 7px 8px; + color: #70787a; + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; +} +.assignment-search input { + height: 32px; + margin-top: 5px; + padding: 0 9px; + font-size: var(--shd-type-telemetry); +} +.assignment-results-head { + height: 27px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 9px; + border-top: 1px solid #262b2c; + border-bottom: 1px solid #262b2c; + color: #687073; + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; +} +.assignment-results-head b { + color: #8a672b; +} +.assignment-results { + max-height: 210px; + overflow: auto; +} +.assignment-results > button { + width: 100%; + min-height: 34px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 9px; + border: 0; + border-top: 1px solid #24292a; + background: #111415; + color: #a4aaac; + text-align: left; + font-size: var(--shd-type-telemetry); + cursor: pointer; +} +.assignment-results > button:first-child { + border-top: 0; +} +.assignment-results > button:hover, +.assignment-results > button:focus-visible { + background: #171b1c; + color: #fff; + box-shadow: inset 2px 0 #f6a821; +} +.assignment-results > button.selected { + background: #211a0e; + color: #f6a821; + box-shadow: inset 2px 0 #f6a821; +} +.assignment-results > button b { + font-size: var(--shd-type-telemetry); + letter-spacing: 0.7px; +} +.assignment-results > button:active { + transform: translateY(1px); + filter: brightness(0.88); +} +.assignment-results > p { + margin: 0; + padding: 25px; + text-align: center; + color: #596164; + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; +} +.assignment-picker.compact { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: minmax(120px, 0.85fr) minmax(135px, 1fr); + min-width: 0; +} +.assignment-picker.compact:focus-within, +.assignment-picker.compact.open { + z-index: 20; + border-color: #8a672b; +} +.assignment-picker.compact .assignment-selected { + grid-template-columns: 42px minmax(0, 1fr); + min-width: 0; + min-height: 40px; + border-right: 1px solid var(--shd-border); + border-bottom: 0; +} +.assignment-picker.compact .assignment-summary span { + font-size: var(--shd-type-telemetry); +} +.assignment-picker.compact .assignment-summary b { + font-size: var(--shd-type-telemetry); +} +.assignment-picker.compact .assignment-chips { + min-width: 0; + padding: 4px; +} +.assignment-picker.compact .assignment-chips > button { + max-width: 100%; + padding: 5px 6px; +} +.assignment-picker.compact .assignment-chips > button span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.assignment-picker.compact .assignment-search { + display: grid; + align-content: center; + padding: 4px; +} +.assignment-picker.compact .assignment-search > span { + display: none; +} +.assignment-picker.compact .assignment-search input { + height: 32px; + margin: 0; +} +.assignment-picker.compact .assignment-results-head, +.assignment-picker.compact .assignment-results { + display: none; + position: absolute; + left: -1px; + right: -1px; + background: #0d1011; +} +.assignment-picker.compact.open .assignment-results-head { + top: 100%; + display: flex; + border: 1px solid #8a672b; + border-bottom-color: var(--shd-border); +} +.assignment-picker.compact.open .assignment-results { + top: calc(100% + 27px); + display: block; + border: 1px solid #8a672b; + border-top: 0; + box-shadow: 0 12px 24px #000b; +} +.assignment-picker.compact .assignment-results > button { + min-height: 38px; +} +@media (max-width: 600px) { + .assignment-picker.compact { + grid-template-columns: 1fr; + } + .assignment-picker.compact .assignment-selected { + border-right: 0; + border-bottom: 1px solid var(--shd-border); + } +} +.registry-pagination { + width: 100%; + height: auto; + min-height: 48px; + margin: 8px 0 0; + padding: 7px 9px; + display: grid; + grid-template-columns: minmax(190px, 260px) auto 1fr auto; + align-items: center; + gap: 12px; + border: 1px solid #303536; + border-bottom: 0; + background: #0f1213; +} +.registry-pagination:not(:has(.pagination-search)) { + grid-template-columns: auto 1fr auto; +} +.pagination-search { + min-width: 0; + display: grid; + grid-template-columns: minmax(100px, 1fr) auto auto; + align-items: center; + gap: 4px; +} +.pagination-search input[type='search'] { + min-width: 0; + height: 30px; + margin: 0; + padding: 0 8px; + font-size: var(--shd-type-telemetry); +} +.pagination-search button, +.pagination-search > a { + height: 30px; + display: grid; + place-items: center; + margin: 0; + padding: 0 9px; + border: 1px solid #3b4142; + background: #111415; + color: #8c9395; + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.6px; + cursor: pointer; +} +.pagination-search button:hover, +.pagination-search button:focus-visible, +.pagination-search > a:hover, +.pagination-search > a:focus-visible { + border-color: #8a672b; + color: var(--shd-orange); +} +.pagination-search button:active, +.pagination-search > a:active { + transform: translateY(1px); + filter: brightness(0.88); +} +.pagination-range { + display: flex; + align-items: center; + gap: 6px; + color: #697174; + font-size: var(--shd-type-telemetry); + letter-spacing: 0.8px; +} +.pagination-range b { + color: #d7d9d5; + font-size: var(--shd-type-telemetry); +} +.pagination-pages { + display: flex; + align-items: center; + justify-content: center; + gap: 4px; +} +.pagination-pages a, +.pagination-sizes a { + min-width: 30px; + height: 30px; + display: grid; + place-items: center; + border: 1px solid #343a3b; + background: #111415; + color: #8c9395; + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.5px; + cursor: pointer; +} +.pagination-pages a:first-child, +.pagination-pages a:last-child { + min-width: 58px; + padding: 0 8px; +} +.pagination-pages a:hover, +.pagination-pages a:focus-visible, +.pagination-sizes a:hover, +.pagination-sizes a:focus-visible { + border-color: #8a672b; + color: #f6a821; +} +.pagination-pages a.active, +.pagination-sizes a.active { + border-color: #f6a821; + background: #211a0e; + color: #f6a821; +} +.pagination-pages a:active, +.pagination-sizes a:active { + transform: translateY(1px); + filter: brightness(0.88); +} +.pagination-pages a.disabled { + opacity: 0.35; + pointer-events: none; +} +.pagination-gap { + color: #555d5f; + font-size: var(--shd-type-telemetry); +} +.pagination-sizes { + display: flex; + align-items: center; + gap: 4px; +} +.pagination-sizes > span { + margin-right: 3px; + color: #697174; + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; +} +.pagination-sizes a { + min-width: 34px; +} +.registry-pagination a:focus-visible { + outline: 2px solid #f6a821; + outline-offset: 2px; +} +.registry-pagination + .dashboard-table { + margin-top: 0; +} +@media (max-width: 1100px) { + .registry-pagination, + .registry-pagination:not(:has(.pagination-search)) { + grid-template-columns: 1fr 1fr; + } + .pagination-search { + grid-column: 1/3; + } + .pagination-pages { + justify-content: flex-end; + } +} +@media (max-width: 650px) { + .registry-pagination, + .registry-pagination:not(:has(.pagination-search)) { + grid-template-columns: 1fr; + } + .pagination-search { + grid-column: auto; + } + .pagination-range, + .pagination-pages, + .pagination-sizes { + justify-content: center; + } +} +@media (max-width: 560px) { + .pagination-search { + grid-template-columns: minmax(0, 1fr) auto; + } + .pagination-search > a { + grid-column: 1/3; + } + .pagination-pages a:not(:first-child):not(:last-child):not(.active), + .pagination-gap { + display: none; + } + .pagination-sizes > span { + display: none; + } +} +@media (max-width: 1200px) { + .weapon-identity-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .weapon-fixed-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .weapon-tier-grid { + grid-template-columns: 1fr; + } + .weapon-talent-pools { + grid-template-columns: 1fr; + } +} +@media (max-width: 700px) { + .weapon-identity-grid, + .weapon-fixed-grid { + grid-template-columns: 1fr 1fr; + } + .compact-selection { + grid-template-columns: 1fr; + } + .base-bonus-row, + .augment-effect, + .augment-effect:not(:has(> button)) { + grid-template-columns: 30px 1fr; + } + .base-bonus-row .field:nth-of-type(2), + .augment-effect .field:nth-of-type(2) { + grid-column: 2; + } + .base-bonus-row > button, + .augment-effect > button { + grid-column: 2; + } + .augment-sequence { + grid-template-columns: 1fr 1fr; + } +} +@media (max-width: 480px) { + .weapon-identity-grid, + .weapon-fixed-grid { + grid-template-columns: 1fr; + } + .weapon-range-row { + grid-template-columns: 45px 1fr 1fr; + } +} +.weapon-tier-card.unavailable { + position: relative; + opacity: 0.42; + border-color: #292d2e; +} +.weapon-tier-card.unavailable .weapon-range-row { + filter: grayscale(1); +} +.tier-unavailable { + margin: 0; + padding: 7px; + border-bottom: 1px solid #282e2f; + background: #0d1011; + color: #7b8284; + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.8px; + text-align: center; +} +.core-range-row > strong { + gap: 7px; +} +.core-icon-stack { + display: flex; + align-items: center; +} +.core-icon-stack .game-icon { + width: 17px; + height: 17px; + margin-left: -7px; +} +.core-icon-stack .game-icon:first-child { + margin-left: 0; +} +.weapon-archive-action { + min-height: 40px; + padding: 0 13px; +} +.weapon-archive-action > b { + font-size: var(--shd-type-telemetry); + transform: rotate(90deg); +} +.legal-table-wrap { + overflow-x: auto; + margin: 18px 0; +} +.legal-table { + width: 100%; + min-width: 720px; + border-collapse: collapse; + border: 1px solid var(--shd-border); + background: #0b0e0f; +} +.legal-table th, +.legal-table td { + padding: 12px 14px; + border: 1px solid var(--shd-border); + text-align: left; + vertical-align: top; +} +.legal-table th { + color: var(--shd-orange); + background: #111516; + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; +} +.legal-table td { + color: #a1a7a8; + font: 12px/1.55 var(--shd-font-editorial); +} +.legal-table code, +.legal-document code { + color: #d9dedf; + font: + 11px Consolas, + monospace; +} +.cookie-notice { + position: fixed; + z-index: 1000; + right: 24px; + bottom: 24px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; + gap: 22px; + width: min(620px, calc(100vw - 48px)); + padding: 18px 20px; + border: 1px solid var(--shd-orange); + background: rgba(10, 13, 14, 0.98); + box-shadow: + 0 14px 48px #000c, + 4px 0 0 var(--shd-orange); +} +.cookie-notice .eyebrow { + margin: 0 0 8px; +} +.cookie-notice div > p:not(.eyebrow) { + margin: 0; + color: var(--shd-text-muted); + font: 12px/1.5 var(--shd-font-editorial); +} +.cookie-notice a { + display: inline-block; + margin-top: 10px; + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 1px; +} +.cookie-notice button { + min-width: 84px; +} +@media (max-width: 600px) { + .cookie-notice { + right: 12px; + bottom: 12px; + grid-template-columns: 1fr; + width: calc(100vw - 24px); + } + .cookie-notice button { + width: 100%; + } +} +.kpi-filters { + height: auto; +} +.kpi-filters > a { + position: relative; + min-height: 70px; + padding: 11px 13px; + background: #111415; + border: 1px solid #2d3233; + border-top: 2px solid #3a4041; + display: grid; + color: inherit; + cursor: pointer; +} +.kpi-filters > a:hover, +.kpi-filters > a:focus-visible { + border-color: #8a672b; + border-top-color: var(--shd-orange); + background: #171a1b; +} +.kpi-filters > a.active { + border-color: #8a672b; + border-top-color: var(--shd-orange); + background: #211a0e; + box-shadow: inset 0 -2px #f6a821; +} +.kpi-filters > a:active { + transform: translateY(1px); + filter: brightness(0.88); +} +.kpi-filters > a span { + font-size: var(--shd-type-telemetry); + letter-spacing: 1.3px; + color: #737a7c; +} +.kpi-filters > a strong { + font-size: 23px; + line-height: 1.1; + margin: 3px 0; +} +.kpi-filters > a small { + font-size: var(--shd-type-telemetry); + color: #555d5f; +} +.kpi-filters > a.active span, +.kpi-filters > a.active strong { + color: var(--shd-orange); +} +.role-kpis { + grid-template-columns: repeat(5, minmax(105px, 1fr)); +} +.role-identity { + grid-template-columns: minmax(220px, 0.7fr) minmax(320px, 1.3fr) !important; +} +.permission-catalogue { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; + margin-top: 8px; +} +.permission-catalogue label { + min-height: 54px; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 9px; + padding: 8px 10px; + border: 1px solid #303536; + background: #0d1011; + cursor: pointer; +} +.permission-catalogue label:hover, +.permission-catalogue label:focus-within { + border-color: #8a672b; + background: #151819; +} +.permission-catalogue label:active { + transform: translateY(1px); + filter: brightness(0.88); +} +.permission-catalogue input { + width: auto; + margin: 0; + accent-color: var(--shd-orange); +} +.permission-catalogue span { + display: grid; + gap: 4px; +} +.permission-catalogue b { + color: #d7d9d5; + font-size: var(--shd-type-telemetry); +} +.permission-catalogue small { + color: #626a6c; + font-size: var(--shd-type-telemetry); + line-height: 1.35; +} +.permission-catalogue em { + color: #596164; + font-size: var(--shd-type-telemetry); + font-style: normal; + font-weight: 900; + letter-spacing: 0.7px; +} +.permission-catalogue label:has(input:checked) { + border-color: #6e5226; + background: #211a0e; +} +.permission-catalogue label:has(input:checked) em { + color: var(--shd-orange); +} +.role-row { + min-width: 960px; + grid-template-columns: 1.1fr 0.55fr 2fr 0.65fr 0.3fr; +} +.role-permission-summary { + display: flex !important; + flex-wrap: wrap; + gap: 3px; +} +.role-permission-summary b { + padding: 3px 5px; + border: 1px solid #3a4840; + color: #75c08c; + font-size: var(--shd-type-telemetry); + letter-spacing: 0.5px; +} +@media (max-width: 1000px) { + .role-kpis { + grid-template-columns: repeat(3, minmax(105px, 1fr)); + } +} +@media (max-width: 700px) { + .permission-catalogue, + .role-identity { + grid-template-columns: 1fr !important; + } + .role-kpis { + grid-template-columns: 1fr 1fr; + } +} +.audit-toolbar > div:last-child { + display: block; +} +.audit-filter { + height: auto; + display: grid; + grid-template-columns: minmax(220px, 1fr) 210px 240px auto; + gap: 7px; + margin: 10px 0; +} +.audit-filter input, +.audit-filter select, +.audit-filter button { + height: 40px; + margin: 0; +} +.audit-filter button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 14px; + line-height: 1; +} +.audit-row { + min-width: 980px; + grid-template-columns: 1fr 1fr 1.3fr 0.85fr 0.35fr; +} +.audit-entry { + border-top: 1px solid #272c2d; +} +.audit-entry:first-of-type { + border-top: 0; +} +.audit-entry summary { + border: 0; + cursor: pointer; + list-style: none; +} +.audit-entry summary::-webkit-details-marker { + display: none; +} +.audit-entry summary:hover { + background: #15191a; +} +.audit-entry summary:active { + transform: translateY(1px); + filter: brightness(0.88); +} +.audit-entry summary > b { + justify-self: end; + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); + letter-spacing: 0.7px; +} +.audit-entry[open] summary { + background: #171a1b; + box-shadow: inset 2px 0 var(--shd-orange); +} +.audit-comparison { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1px; + padding: 1px; + background: #303536; +} +.audit-comparison section { + min-width: 0; + padding: 12px; + background: #0b0e0f; +} +.audit-comparison span { + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 1px; +} +.audit-comparison pre { + max-height: 320px; + overflow: auto; + margin: 9px 0 0; + color: #aeb4b5; + font: + var(--shd-type-telemetry)/1.5 Consolas, + monospace; + white-space: pre-wrap; + word-break: break-word; +} +@media (max-width: 900px) { + .audit-filter { + grid-template-columns: 1fr 1fr; + } + .audit-comparison { + grid-template-columns: 1fr; + } +} +@media (max-width: 560px) { + .audit-filter { + grid-template-columns: 1fr; + } +} +.audit-kpis { + grid-template-columns: repeat(5, minmax(120px, 1fr)); +} +.access-baseline-note { + display: flex; + align-items: center; + gap: 10px; + margin: 9px 0; + padding: 8px 10px; + border-left: 2px solid var(--shd-info); + background: #0e1518; + color: #94c3d5; +} +.access-baseline-note b { + flex: 0 0 auto; + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; +} +.access-baseline-note span { + color: #6f929f; + font-size: var(--shd-type-telemetry); +} +.access-baseline-note.compact { + margin: 0 0 8px; +} +.audit-revert-bar { + min-height: 56px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 12px; + border-top: 1px solid #4c302d; + background: #1c1111; +} +.audit-revert-bar > span { + color: #a87870; + font-size: var(--shd-type-telemetry); + letter-spacing: 0.8px; +} +.audit-revert-button { + min-width: 174px; + height: 40px; + padding: 0 14px; +} +.audit-revert-button > b { + font-size: 15px; + line-height: 1; +} +.audit-revert-button:hover, +.audit-revert-button:focus-visible { + box-shadow: inset 3px 0 var(--shd-danger); +} +.audit-revert-bar.completed { + border-color: #36563f; + background: #101713; +} +.audit-revert-bar.completed > span { + color: var(--shd-success); +} +.audit-revert-modal > p { + color: #969d9f; + font: 13px/1.6 var(--shd-font-editorial); +} +.audit-revert-modal code { + color: #d7d9d5; + font: + 11px Consolas, + monospace; +} +.audit-revert-summary { + display: grid; + grid-template-columns: 1fr 1fr 1.4fr; + gap: 7px; + margin: 14px 0; +} +.audit-revert-summary > div { + min-width: 0; + display: grid; + gap: 5px; + padding: 9px; + border: 1px solid #303536; + background: #0d1011; +} +.audit-revert-summary span { + color: #626a6c; + font-size: var(--shd-type-telemetry); + letter-spacing: 0.8px; +} +.audit-revert-summary b { + overflow: hidden; + text-overflow: ellipsis; + color: #d7d9d5; + font-size: var(--shd-type-telemetry); + white-space: nowrap; +} +@media (max-width: 600px) { + .audit-revert-bar { + align-items: stretch; + flex-direction: column; + } + .audit-revert-button { + width: 100%; + } + .audit-revert-summary { + grid-template-columns: 1fr; + } +} +.metrics-auto { + height: 40px; + display: flex !important; + align-items: center; + gap: 7px; + padding: 0 10px; + border: 1px solid var(--shd-border); + background: #0d1011; + color: #747c7e; + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.8px; + cursor: pointer; +} +.metrics-auto input { + width: auto; + margin: 0; + accent-color: var(--shd-orange); +} +.metrics-auto:has(input:checked) { + border-color: #6e5226; + background: #211a0e; + color: var(--shd-orange); +} +.metrics-refresh { + min-width: 154px; +} +.metrics-kpis strong { + font-size: 18px; +} +.metrics-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} +.metrics-panel { + min-width: 0; + border: 1px solid var(--shd-border); + background: var(--shd-surface); +} +.metrics-panel > header { + min-height: 64px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding: 11px 13px; + border-bottom: 1px solid var(--shd-border); + background: #0d1011; +} +.metrics-panel h3 { + margin: 4px 0 0; + font-size: 14px; + letter-spacing: 0.5px; +} +.metrics-panel > header > b { + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; +} +.metric-lines { + display: grid; +} +.metric-lines > div { + min-height: 40px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 8px 12px; + border-top: 1px solid #252a2b; +} +.metric-lines > div:first-child { + border-top: 0; +} +.metric-lines span { + color: #697174; + font-size: var(--shd-type-telemetry); + letter-spacing: 0.9px; +} +.metric-lines b { + max-width: 65%; + overflow: hidden; + color: #d5d8d5; + font-size: var(--shd-type-telemetry); + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; +} +.metrics-foot { + margin: 10px 0 0; + padding: 9px; + border: 1px solid #292e2f; + color: #596164; + font-size: var(--shd-type-telemetry); + letter-spacing: 0.8px; +} +@media (max-width: 850px) { + .metrics-grid { + grid-template-columns: 1fr; + } +} +@media (max-width: 560px) { + .metrics-auto, + .metrics-refresh { + width: 100%; + } + .metric-lines b { + max-width: 58%; + } +} +.quality-chip { + display: inline-flex; + width: max-content; + align-items: center; + padding: 4px 6px; + border: 1px solid currentColor; + background: #1115; + color: var(--quality-standard); + font-size: var(--shd-type-telemetry); + letter-spacing: 0.7px; + text-transform: uppercase; +} +.quality-standard { + color: var(--quality-standard) !important; +} +.quality-specialized { + color: var(--quality-specialized) !important; +} +.quality-superior { + color: var(--quality-superior) !important; +} +.quality-highend { + color: var(--quality-high-end) !important; +} +.protocol-editor { + border-top-color: var(--quality-specialized); +} +.protocol-identity-grid { + display: grid; + grid-template-columns: 1.4fr 0.7fr 1.5fr; + gap: 8px; +} +.protocol-type-picker { + min-width: 0; + margin: 0; + padding: 0; + border: 0; +} +.protocol-type-picker legend { + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + font-weight: bold; + letter-spacing: 1px; +} +.protocol-type-picker > div { + height: 32px; + display: grid; + grid-template-columns: repeat(3, 1fr); + margin-top: 5px; + border: 1px solid #363b3c; + background: #0d0f10; +} +.protocol-type-picker label { + min-width: 0; + border-left: 1px solid var(--shd-border); + cursor: pointer; +} +.protocol-type-picker label:first-child { + border-left: 0; +} +.protocol-type-picker input { + position: absolute; + opacity: 0; + pointer-events: none; +} +.protocol-type-picker span { + height: 100%; + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + color: #737a7c; +} +.protocol-type-picker .game-icon { + width: 18px; + height: 18px; + opacity: 0.55; +} +.protocol-type-picker b { + font-size: var(--shd-type-telemetry); +} +.protocol-type-picker label:hover span { + background: #171b1c; + color: var(--shd-text); +} +.protocol-type-picker label:active span { + transform: translateY(1px); + filter: brightness(0.88); +} +.protocol-type-picker input:checked + span { + background: #211a0e; + color: var(--shd-orange); + box-shadow: inset 0 -2px var(--shd-orange); +} +.protocol-type-picker input:checked + span .game-icon { + opacity: 1; +} +.protocol-type-picker input:focus-visible + span { + outline: 2px solid var(--shd-orange); + outline-offset: 2px; +} +.protocol-level-preview { + display: grid; + grid-template-columns: minmax(220px, 1fr); + gap: 3px; + padding: 8px 10px; + border: 1px solid var(--shd-border); + background: #0d1011; +} +.protocol-level-preview input { + width: 100%; + margin: 0; + accent-color: var(--shd-orange); +} +.protocol-level-preview > div { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 7px; + color: #626a6c; + font-size: var(--shd-type-telemetry); +} +.protocol-level-preview i { + height: 2px; + background: var(--shd-orange); +} +.protocol-attribute-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 7px; + margin-top: 7px; +} +.protocol-attribute-card { + display: grid; + gap: 6px; + padding: 8px; + border: 1px solid var(--shd-border); + background: #101314; +} +.protocol-attribute-card header { + min-height: 38px; + display: flex; + align-items: center; + gap: 6px; + border-bottom: 1px solid #282e2f; +} +.protocol-attribute-card header .game-icon { + width: 25px; + height: 25px; +} +.protocol-attribute-card header div { + display: grid; + gap: 2px; +} +.protocol-attribute-card header span { + color: #626a6c; + font-size: var(--shd-type-telemetry); + letter-spacing: 0.8px; +} +.protocol-attribute-card header b { + font-size: var(--shd-type-telemetry); +} +.protocol-attribute-card header > strong { + margin-left: auto; + color: var(--shd-orange); + font-size: 12px; +} +.protocol-attribute-card .field { + margin: 0; +} +.protocol-attribute-card > small { + color: #626a6c; + font-size: var(--shd-type-telemetry); + text-align: right; +} +.protocol-talent { + margin-top: 12px; + padding: 8px; + border: 1px solid #71552a; + background: #0d1011; +} +.protocol-talent-identity { + display: grid; + grid-template-columns: 0.7fr 1.3fr; + gap: 8px; +} +.protocol-talent-identity textarea { + min-height: 65px; +} +.protocol-upgrade-preview { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 8px; + margin: 8px 0; +} +.diamond-control { + display: flex; + align-items: stretch; + border: 1px solid var(--shd-border); +} +.diamond-control button { + min-width: 42px; + display: grid; + place-items: center; + padding: 5px; + border: 0; + border-left: 1px solid var(--shd-border); + background: #111415; + color: #596164; + cursor: pointer; +} +.diamond-control button:first-child { + border-left: 0; + font-size: var(--shd-type-telemetry); + font-weight: 900; +} +.diamond-control button span { + font-size: 15px; +} +.diamond-control button small { + font-size: var(--shd-type-telemetry); +} +.diamond-control button:hover, +.diamond-control button:focus-visible { + background: #171b1c; + color: var(--shd-text); +} +.diamond-control button:active { + transform: translateY(1px); + filter: brightness(0.88); +} +.diamond-control button.active { + background: #211a0e; + color: var(--shd-orange); + box-shadow: inset 0 -2px var(--shd-orange); +} +.protocol-upgrade-preview > div:last-child { + padding: 8px 10px; + border: 1px solid var(--shd-border); + background: #101314; +} +.protocol-upgrade-preview > div:last-child span { + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); + letter-spacing: 0.8px; +} +.protocol-upgrade-preview p { + margin: 6px 0 0; + color: #d7d9d5; + font-size: var(--shd-type-telemetry); + line-height: 1.45; +} +.protocol-talent-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} +.protocol-talent-effects { + display: grid; + border: 1px solid var(--shd-border); +} +.protocol-talent-effects > div { + display: grid; + grid-template-columns: 28px minmax(140px, 1fr) 90px 80px 28px; + gap: 5px; + align-items: center; + padding: 5px; + border-top: 1px solid #282e2f; +} +.protocol-talent-effects > div:first-child { + border-top: 0; +} +.protocol-talent-effects strong { + display: grid; + place-items: center; + height: 100%; + background: #171b1c; + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); +} +.protocol-talent-effects select, +.protocol-talent-effects input { + min-width: 0; + margin: 0; + padding: 7px; + font-size: var(--shd-type-telemetry); +} +.protocol-talent-effects span { + color: #626a6c; + font-size: var(--shd-type-telemetry); + text-align: center; +} +.protocol-talent-effects button { + height: 28px; + border: 1px solid #563734; + background: #1d1212; + color: var(--shd-danger); + cursor: pointer; +} +.protocol-talent-effects button:disabled { + opacity: 0.3; + cursor: not-allowed; +} +.protocol-row { + min-width: 1050px; + grid-template-columns: 1.2fr 0.65fr 0.8fr 0.7fr 1.1fr 0.55fr 0.3fr; +} +.protocol-type-label { + display: flex !important; + align-items: center; + gap: 5px; +} +.protocol-type-label .game-icon { + width: 20px; + height: 20px; +} +.mini-diamonds { + color: var(--shd-orange); + letter-spacing: 1px; +} +@media (max-width: 1000px) { + .protocol-identity-grid { + grid-template-columns: 1fr 1fr; + } + .protocol-type-picker { + grid-column: 1/3; + } + .protocol-attribute-grid, + .protocol-talent-grid { + grid-template-columns: 1fr; + } + .protocol-upgrade-preview { + grid-template-columns: 1fr; + } +} +@media (max-width: 650px) { + .protocol-identity-grid, + .protocol-talent-identity { + grid-template-columns: 1fr; + } + .protocol-type-picker { + grid-column: auto; + } + .protocol-type-picker b { + font-size: var(--shd-type-telemetry); + } + .diamond-control { + overflow-x: auto; + } + .protocol-talent-effects > div { + grid-template-columns: 26px 1fr; + } + .protocol-talent-effects input, + .protocol-talent-effects span, + .protocol-talent-effects button { + grid-column: 2; + } +} +.protocol-profile-registry { + margin-bottom: 10px; + border: 1px solid var(--shd-border); + background: #0d1011; +} +.protocol-profile-registry > summary { + margin: 0; + padding: 9px 10px; + cursor: pointer; + list-style: none; +} +.protocol-profile-registry > summary::-webkit-details-marker { + display: none; +} +.protocol-profile-registry > summary:hover, +.protocol-profile-registry > summary:focus-visible { + background: #171b1c; +} +.protocol-profile-registry > summary:active { + transform: translateY(1px); + filter: brightness(0.88); +} +.protocol-profile-registry > summary > div:last-child { + display: flex; + align-items: center; + gap: 10px; +} +.protocol-profile-registry > summary i { + color: var(--shd-orange); + font-size: 13px; + font-style: normal; + transition: transform 0.16s ease; +} +.protocol-profile-registry[open] > summary { + border-bottom: 1px solid var(--shd-border); + background: #131718; +} +.protocol-profile-registry[open] > summary i { + transform: rotate(45deg); +} +.protocol-profile-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 5px; + padding: 8px; +} +.protocol-profile-grid > button { + min-width: 0; + padding: 7px; + border: 1px solid var(--shd-border); + background: #111415; + color: var(--shd-text-muted); + text-align: left; + cursor: pointer; +} +.protocol-profile-grid > button:hover, +.protocol-profile-grid > button:focus-visible { + border-color: #8a672b; + background: #171b1c; +} +.protocol-profile-grid > button:active { + transform: translateY(1px); + filter: brightness(0.88); +} +.protocol-profile-grid > button.configured { + border-left: 2px solid var(--shd-success); +} +.protocol-profile-grid header { + display: flex; + align-items: center; + gap: 6px; + padding-bottom: 6px; + border-bottom: 1px solid #292e2f; +} +.protocol-profile-grid header .game-icon { + width: 23px; + height: 23px; +} +.protocol-profile-grid header div { + display: grid; + gap: 2px; +} +.protocol-profile-grid header b { + font-size: var(--shd-type-telemetry); + text-transform: uppercase; +} +.protocol-profile-grid header strong { + color: #d7d9d5; + font-size: var(--shd-type-telemetry); +} +.protocol-profile-grid header > span { + margin-left: auto; + color: #6b7274; + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.5px; +} +.protocol-profile-grid > button.configured header > span { + color: var(--shd-success); +} +.protocol-profile-grid > button > div { + display: grid; + gap: 4px; + margin-top: 6px; +} +.protocol-profile-grid > button > div span { + display: flex; + justify-content: space-between; + gap: 6px; +} +.protocol-profile-grid > button > div small { + overflow: hidden; + color: #626a6c; + font-size: var(--shd-type-telemetry); + text-overflow: ellipsis; + white-space: nowrap; +} +.protocol-profile-grid > button > div b { + color: #b8bdbe; + font-size: var(--shd-type-telemetry); +} +.protocol-profile-editor { + border-top-color: var(--shd-success); +} +.shared-secondary-editor { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 7px; +} +.selected-secondary-profile { + margin: 8px 0; + border: 1px solid #36563f; + background: #101713; +} +.selected-secondary-profile.unconfigured { + border-color: #65403a; + background: #1c1111; +} +.selected-secondary-profile header { + display: flex; + justify-content: space-between; + padding: 7px 9px; + border-bottom: 1px solid currentColor; + color: var(--shd-success); + font-size: var(--shd-type-telemetry); + letter-spacing: 0.8px; +} +.selected-secondary-profile.unconfigured header { + color: var(--shd-danger); +} +.selected-secondary-profile > div { + display: grid; + grid-template-columns: repeat(3, 1fr); +} +.selected-secondary-profile > div span { + display: flex; + justify-content: space-between; + padding: 8px 9px; + border-left: 1px solid #2b3d30; +} +.selected-secondary-profile > div span:first-child { + border-left: 0; +} +.selected-secondary-profile small { + color: #6f7b72; + font-size: var(--shd-type-telemetry); +} +.selected-secondary-profile b { + font-size: var(--shd-type-telemetry); +} +.selected-secondary-profile p { + margin: 0; + padding: 10px; + color: #a87870; + font-size: var(--shd-type-telemetry); +} +.protocol-primary-editor { + display: grid; + grid-template-columns: 1fr 180px 180px; + gap: 7px; + margin-top: 7px; + padding: 8px; + border: 1px solid var(--shd-border); + background: #101314; +} +.protocol-primary-editor .field { + margin: 0; +} +.protocol-primary-editor > div { + display: grid; + align-content: center; + gap: 4px; + padding: 0 10px; + border-left: 2px solid var(--shd-orange); +} +.protocol-primary-editor > div span, +.protocol-primary-editor > div small { + color: #626a6c; + font-size: var(--shd-type-telemetry); +} +.protocol-primary-editor > div b { + color: var(--shd-orange); + font-size: 15px; +} +.protocol-row { + grid-template-columns: 1.2fr 0.65fr 0.8fr 1fr 1.1fr 0.55fr 0.3fr; +} +@media (max-width: 1100px) { + .protocol-profile-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} +@media (max-width: 1000px) { + .protocol-profile-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .protocol-primary-editor { + grid-template-columns: 1fr 1fr; + } + .protocol-primary-editor > div { + grid-column: 1/3; + } +} +@media (max-width: 650px) { + .protocol-profile-grid, + .selected-secondary-profile > div, + .protocol-primary-editor, + .shared-secondary-editor { + grid-template-columns: 1fr; + } + .selected-secondary-profile > div span { + border-left: 0; + border-top: 1px solid #2b3d30; + } + .protocol-primary-editor > div { + grid-column: auto; + } +} +.weapon-exotic-talent { + margin: 10px 0; + padding: 10px; + border: 1px solid #765426; + border-top: 2px solid var(--shd-orange); + background: #151208; +} +.exotic-talent-identity { + grid-template-columns: minmax(180px, 0.65fr) minmax(320px, 1.35fr) !important; +} +.exotic-talent-identity textarea { + min-height: 64px; +} +.exotic-talent-identity small { + display: block; + margin-top: 5px; + color: #69665e; + font-size: var(--shd-type-telemetry); +} +.weapon-exotic-grid { + display: grid; + grid-template-columns: 0.8fr 1.2fr; + gap: 8px; + margin-top: 8px; +} +.exotic-effect-list { + display: grid; + gap: 5px; +} +.exotic-effect-row { + display: grid; + grid-template-columns: 28px minmax(150px, 1fr) 85px 85px 28px; + gap: 5px; + align-items: center; +} +.exotic-effect-row > strong { + height: 32px; + display: grid; + place-items: center; + background: #211a0e; + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); +} +.exotic-effect-row select, +.exotic-effect-row input { + height: 32px; + min-width: 0; + margin: 0; + padding: 0 7px; + font-size: var(--shd-type-telemetry); +} +.exotic-effect-row button { + height: 32px; + border: 1px solid #563734; + background: #1d1212; + color: #da746c; + cursor: pointer; +} +.exotic-effect-row button:disabled { + opacity: 0.35; + cursor: not-allowed; +} +@media (max-width: 1050px) { + .weapon-exotic-grid { + grid-template-columns: 1fr; + } +} +@media (max-width: 700px) { + .exotic-talent-identity { + grid-template-columns: 1fr !important; + } + .exotic-effect-row { + grid-template-columns: 28px 1fr 1fr; + } + .exotic-effect-row select { + grid-column: 2/4; + } + .exotic-effect-row button { + grid-column: 3; + } +} +.protocol-identity-grid { + grid-template-columns: minmax(280px, 1.35fr) minmax(420px, 1fr); + gap: 12px; +} +.protocol-classification-stack { + display: grid; + gap: 7px; +} +.protocol-quality-picker { + min-width: 0; + margin: 0; + padding: 0; + border: 0; +} +.protocol-quality-picker legend { + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + font-weight: bold; + letter-spacing: 1px; +} +.protocol-quality-picker > div { + height: 32px; + display: grid; + grid-template-columns: repeat(4, 1fr); + margin-top: 5px; + border: 1px solid #363b3c; + background: #0d0f10; +} +.protocol-quality-picker label { + min-width: 0; + border-left: 1px solid var(--shd-border); + cursor: pointer; +} +.protocol-quality-picker label:first-child { + border-left: 0; +} +.protocol-quality-picker input { + position: absolute; + opacity: 0; + pointer-events: none; +} +.protocol-quality-picker span { + height: 100%; + display: flex; + align-items: center; + justify-content: center; + gap: 4px; +} +.protocol-quality-picker span i { + width: 5px; + height: 5px; + background: currentColor; +} +.protocol-quality-picker b { + font-size: var(--shd-type-telemetry); + text-transform: uppercase; +} +.protocol-quality-picker label:hover span { + background: #171b1c; + filter: brightness(1.2); +} +.protocol-quality-picker label:active span { + transform: translateY(1px); + filter: brightness(0.88); +} +.protocol-quality-picker input:checked + span { + background: #171b1c; + box-shadow: inset 0 -2px currentColor; +} +.protocol-quality-picker input:focus-visible + span { + outline: 2px solid var(--shd-orange); + outline-offset: 2px; +} +@media (max-width: 1000px) { + .protocol-identity-grid { + grid-template-columns: 1fr; + } +} +@media (max-width: 650px) { + .protocol-quality-picker b { + font-size: var(--shd-type-telemetry); + } +} +.icon-editor-identity, +.talent-icon-identity, +.exotic-icon-identity { + display: grid; + grid-template-columns: var(--isac-icon-editor-width) minmax(0, 1fr); + gap: 9px; + align-items: start; +} +.icon-editor-identity > .field { + margin: 0; +} +.talent-icon-identity > .form-grid, +.exotic-icon-identity > .form-grid { + min-width: 0; +} +.icon-upload-field { + width: var(--isac-icon-editor-width); + max-width: 100%; + min-width: 0; + margin: 0; + padding: 7px; + border: 1px solid var(--shd-border); + background: #0d1011; +} +.icon-upload-field legend { + padding: 0 5px; + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.8px; +} +.icon-upload-preview { + height: 86px; + display: grid; + place-items: center; + margin-bottom: 6px; + border: 1px solid #292f30; + background: #111415; +} +.icon-upload-preview img { + width: 72px; + height: 72px; + object-fit: contain; +} +.icon-upload-preview > span { + color: #596164; + font-size: var(--shd-type-telemetry); + letter-spacing: 0.8px; +} +.icon-upload-field > label { + min-height: 30px; + display: grid; + place-items: center; + border: 1px solid #424849; + background: #15191a; + color: #d7d9d5; + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.6px; + cursor: pointer; +} +.icon-upload-field > label:hover, +.icon-upload-field > label:focus-within { + border-color: var(--shd-orange); + color: var(--shd-orange); +} +.icon-upload-field > label:active { + transform: translateY(1px); + filter: brightness(0.88); +} +.icon-upload-field > label input[type='file'] { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; +} +.icon-upload-field > label.icon-remove { + min-height: 24px; + display: flex; + gap: 5px; + margin-top: 5px; + border-color: #563734; + background: #1d1212; + color: var(--shd-danger); +} +.icon-upload-field > label.icon-remove input { + width: auto; + margin: 0; + accent-color: var(--shd-danger); +} +.icon-upload-field > small { + display: block; + margin-top: 5px; + color: #596164; + font-size: var(--shd-type-telemetry); + line-height: 1.4; +} +.dashboard-editor input[name='name'] { + min-height: 46px; + padding: 9px 11px; + font-size: 15px; + font-weight: 800; + letter-spacing: 0.35px; +} +.registry-icon-identity { + display: flex !important; + align-items: center; + gap: 7px; + min-width: 0; +} +.registry-icon-identity > img, +.registry-icon-identity > span { + flex: 0 0 30px; + width: 30px; + height: 30px; + object-fit: contain; +} +.registry-icon-identity > span { + display: grid; + place-items: center; + border: 1px solid #303536; + color: #596164; +} +.registry-icon-identity > div { + display: grid; + min-width: 0; +} +.registry-icon-identity > div strong, +.registry-icon-identity > div small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +@media (max-width: 700px) { + .icon-editor-identity, + .talent-icon-identity, + .exotic-icon-identity { + grid-template-columns: 1fr; + } + .icon-upload-field { + width: 100%; + max-width: none; + } + .icon-upload-preview { + height: 70px; + } +} +.brand-editor-body { + display: grid; + grid-template-columns: var(--isac-icon-editor-width) minmax(0, 1fr); + gap: 10px; + margin-top: 8px; + align-items: start; +} +.brand-editor-body > div { + min-width: 0; +} +.brand-set-editor .brand-name { + max-width: none; + margin: 0 0 7px; +} +.brand-set-editor .icon-upload-field { + padding: 6px; +} +.brand-set-editor .icon-upload-preview { + height: 58px; + margin-bottom: 5px; +} +.brand-set-editor .icon-upload-preview img { + width: 50px; + height: 50px; +} +.brand-set-editor .icon-upload-field > label { + min-height: 25px; +} +.brand-set-editor .icon-upload-field > label.icon-remove { + min-height: 22px; +} +.brand-set-editor .effect-band { + padding: 7px; +} +.brand-set-editor .form-actions { + margin-top: 10px; + padding-top: 10px; +} +.brand-archive-action { + min-height: 40px; + padding: 0 13px; +} +.brand-archive-action > b { + font-size: var(--shd-type-telemetry); + transform: rotate(90deg); +} +@media (max-width: 850px) { + .brand-editor-body { + grid-template-columns: 1fr; + } + .brand-set-editor .icon-upload-field { + width: 100%; + display: grid; + grid-template-columns: 70px 1fr; + gap: 5px; + } + .brand-set-editor .icon-upload-field legend, + .brand-set-editor .icon-upload-field > small { + grid-column: 1/3; + } + .brand-set-editor .icon-upload-preview { + grid-row: 1/3; + height: 55px; + margin: 0; + } + .brand-set-editor .icon-upload-field > label { + min-height: 25px; + } +} +@media (max-width: 560px) { + .brand-set-editor .icon-upload-field { + grid-template-columns: 1fr; + } + .brand-set-editor .icon-upload-field legend, + .brand-set-editor .icon-upload-field > small { + grid-column: auto; + } + .brand-set-editor .icon-upload-preview { + grid-row: auto; + } + .brand-archive-action { + width: 100%; + } +} +.brand-archive-action.restore > b { + transform: none; +} +.moderation-action.restore { + border: 1px solid #3d6849; + background: #111d15; + color: var(--shd-success); +} +.moderation-action.restore:hover, +.moderation-action.restore:focus-visible { + border-color: #69b77d; + background: #17271c; + color: #a4e5b6; +} +.pending-reference, +.dashboard-row.pending-record { + border-color: color-mix(in srgb, var(--shd-orange) 58%, #303536) !important; + background: color-mix(in srgb, var(--shd-orange) 7%, #121516) !important; +} +.pending-reference-label, +.state-dot.pending { + color: var(--shd-orange); + font-family: var(--font-display); + font-size: 0.62rem; + letter-spacing: 0.08em; + white-space: nowrap; +} +.state-dot.pending:before { + background: var(--shd-orange); +} +.data-access-banner { + justify-content: space-between; +} +.data-access-banner > div { + display: grid; + gap: 3px; +} +.data-access-banner form { + margin: 0; +} +.mode-toggle { + min-height: 28px; + display: inline-flex; + align-items: center; + padding: 0 9px; + border: 1px solid #4b5152; + background: #15191a; + color: #c7cdce; + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.65px; + white-space: nowrap; + cursor: pointer; +} +.mode-toggle:hover, +.mode-toggle:focus-visible { + border-color: var(--shd-orange); + color: var(--shd-orange); +} +.mode-toggle:active { + transform: translateY(1px); + filter: brightness(0.86); +} +.contributor-guidelines-toolbar { + align-items: flex-end; +} +.guideline-version { + min-width: 120px; + display: grid; + gap: 4px; + padding: 8px 10px; + border: 1px solid var(--shd-border); + background: #111415; +} +.guideline-version span { + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; +} +.guideline-version strong { + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); +} +.contributor-guidelines { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 7px; + margin: 10px 0; +} +.contributor-guidelines section { + min-height: 122px; + display: grid; + grid-template-columns: 34px 1fr; + border: 1px solid var(--shd-border); + background: #111415; +} +.contributor-guidelines section > span { + display: grid; + place-items: start center; + padding-top: 11px; + border-right: 1px solid var(--shd-border); + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); + font-weight: 900; +} +.contributor-guidelines section > div { + padding: 11px; +} +.contributor-guidelines h3 { + margin: 0 0 6px; + font-size: var(--shd-type-telemetry); + letter-spacing: 0.7px; +} +.contributor-guidelines p { + margin: 0; + color: #858d8f; + font-size: var(--shd-type-telemetry); + line-height: 1.55; +} +.contributor-acceptance { + display: flex; + align-items: flex-start; + gap: 9px; + padding: 11px; + border: 1px solid #6f511c; + background: #19150e; + cursor: pointer; +} +.contributor-acceptance input { + width: auto; + margin: 2px 0 0; + accent-color: var(--shd-orange); +} +.contributor-acceptance span { + display: grid; + gap: 4px; +} +.contributor-acceptance b { + color: #e3c487; + font-size: var(--shd-type-telemetry); + letter-spacing: 0.7px; +} +.contributor-acceptance small { + color: #917d59; + font-size: var(--shd-type-telemetry); +} +.form-actions a.button { + display: inline-flex; + align-items: center; + justify-content: center; + text-decoration: none; +} +@media (max-width: 760px) { + .contributor-guidelines { + grid-template-columns: 1fr; + } + .data-access-banner { + align-items: flex-start; + flex-direction: column; + } + .mode-toggle { + width: 100%; + justify-content: center; + } +} +.command-status .data-mode-status { + border-left: 2px solid #667174; +} +.command-status .data-mode-status strong { + color: #b8c0c1; + white-space: nowrap; +} +.command-status .data-mode-status.contributor { + border-left-color: var(--shd-orange); +} +.command-status .data-mode-status.contributor strong { + color: #f2b64d; +} +.command-status .data-mode-status.approver { + border-left-color: #68a9c4; +} +.command-status .data-mode-status.approver strong { + color: #8ecce5; +} +.command-status .data-mode-status.data-manager, +.command-status .data-mode-status.admin { + border-left-color: #75c08c; +} +.command-status .data-mode-status.data-manager strong, +.command-status .data-mode-status.admin strong { + color: #8bd49f; +} +@media (max-width: 900px) { + .admin-command { + align-items: flex-start; + gap: 12px; + } + .command-status { + display: grid; + grid-template-columns: repeat(2, minmax(105px, 1fr)); + } +} +@media (max-width: 560px) { + .admin-command { + display: grid; + } + .command-status { + width: 100%; + } + .command-status > div { + min-width: 0; + } +} +.data-readonly .module-toolbar button, +.data-readonly .dashboard-row:not(.table-heading) > button, +.data-readonly .dashboard-editor, +.data-readonly form[method='post'] { + display: none !important; +} +.command-status .data-mode-status.oversight { + border-left-color: #68a9c4; +} +.command-status .data-mode-status.oversight strong { + color: #8ecce5; +} +.oversight-decision-tabs { + grid-template-columns: repeat(3, 1fr); +} +.proposal-correction-head { + display: flex; + align-items: center; + justify-content: space-between; + margin: 10px 0 6px; + padding: 8px 9px; + border: 1px solid var(--shd-border); + background: #0d1011; +} +.proposal-correction-head > div { + display: grid; + gap: 3px; +} +.proposal-correction-head span { + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.8px; +} +.proposal-correction-head small { + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); +} +.proposal-correction-head > b { + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); +} +.proposal-correction-grid { + max-height: 340px; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; + overflow: auto; + padding: 7px; + border: 1px solid var(--shd-border); + background: #0b0e0f; +} +.proposal-correction-grid .field { + margin: 0; +} +.proposal-correction-grid .field > span { + display: flex; + justify-content: space-between; +} +.proposal-correction-grid .field > span small { + color: #61696b; + font-size: var(--shd-type-telemetry); +} +.proposal-correction-grid textarea { + min-height: 74px; + resize: vertical; +} +.immutable-proposal-file { + display: grid; + align-content: center; + gap: 4px; + min-height: 54px; + padding: 8px; + border: 1px dashed #394041; + color: var(--shd-text-muted); +} +.immutable-proposal-file span, +.immutable-proposal-file small { + font-size: var(--shd-type-telemetry); +} +.immutable-proposal-file b { + color: var(--shd-text); + font-size: var(--shd-type-telemetry); +} +@media (max-width: 700px) { + .oversight-decision-tabs, + .proposal-correction-grid { + grid-template-columns: 1fr; + } +} +.mobile-nav-toggle, +.mobile-nav-layer { + display: none; +} +@media (max-width: 900px) { + .site-header { + position: relative; + z-index: 40; + padding: 0 16px; + } + .site-header > .account, + .site-header > .sign-in { + display: none; + } + .mobile-nav-toggle { + width: 52px; + height: 44px; + display: grid; + grid-template-columns: 1fr; + align-content: center; + gap: 4px; + margin-left: auto; + padding: 6px 8px; + border: 1px solid var(--shd-border); + background: #111415; + color: var(--shd-text); + cursor: pointer; + } + .mobile-nav-toggle > span { + width: 20px; + height: 1px; + margin-left: auto; + background: var(--shd-orange); + transition: + transform 0.14s ease, + opacity 0.14s ease; + } + .mobile-nav-toggle > b { + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; + text-align: right; + } + .mobile-nav-toggle[aria-expanded='true'] > span:nth-child(1) { + transform: translateY(5px) rotate(45deg); + } + .mobile-nav-toggle[aria-expanded='true'] > span:nth-child(2) { + opacity: 0; + } + .mobile-nav-toggle[aria-expanded='true'] > span:nth-child(3) { + transform: translateY(-5px) rotate(-45deg); + } + .mobile-nav-toggle:hover, + .mobile-nav-toggle:focus-visible { + border-color: var(--shd-orange); + background: #171b1c; + } + .mobile-nav-toggle:active { + transform: translateY(1px); + filter: brightness(0.86); + } + .mobile-nav-layer { + position: fixed; + z-index: 35; + inset: 76px 0 0; + display: grid; + grid-template-columns: 1fr minmax(270px, 82vw); + } + .mobile-nav-backdrop { + grid-column: 1; + grid-row: 1; + width: 100%; + height: 100%; + padding: 0; + border: 0; + background: #050607c9; + backdrop-filter: blur(2px); + cursor: pointer; + } + .mobile-navigation { + grid-column: 2; + grid-row: 1; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + margin: 0; + overflow-y: auto; + border-left: 1px solid #4a5051; + background: #0d1011; + box-shadow: -18px 0 40px #000b; + } + .mobile-nav-heading { + min-height: 61px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 11px 14px; + border-bottom: 1px solid var(--shd-border); + background: #111415; + } + .mobile-nav-heading > div { + display: grid; + gap: 4px; + } + .mobile-nav-heading span { + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + letter-spacing: 1.2px; + } + .mobile-nav-heading strong { + font-size: 13px; + letter-spacing: 1px; + } + .mobile-nav-heading > b { + color: var(--shd-success); + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; + } + .mobile-navigation > a { + min-height: 54px; + display: grid; + grid-template-columns: 42px 1fr 20px; + align-items: center; + padding: 0 13px 0 0; + border-bottom: 1px solid var(--shd-border-soft); + color: #a1a8a9; + } + .mobile-navigation > a small { + height: 100%; + display: grid; + place-items: center; + background: #111415; + color: #555d5f; + font-size: var(--shd-type-telemetry); + letter-spacing: 0.7px; + } + .mobile-navigation > a span { + padding-left: 13px; + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 1px; + text-transform: uppercase; + } + .mobile-navigation > a > b { + color: var(--shd-orange); + font-size: 18px; + } + .mobile-navigation > a:hover, + .mobile-navigation > a:focus-visible, + .mobile-navigation > a.active { + background: #171b1c; + color: #fff; + } + .mobile-navigation > a.active { + box-shadow: inset 2px 0 var(--shd-orange); + } + .mobile-navigation > a.active small { + background: #211a0e; + color: var(--shd-orange); + } + .mobile-navigation > a:active { + filter: brightness(0.82); + } + .mobile-agent-state { + display: grid; + gap: 4px; + margin-top: auto; + padding: 14px; + border-top: 1px solid var(--shd-border); + } + .mobile-agent-state span { + color: #747c7e; + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; + } + .mobile-agent-state i { + display: inline-block; + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--shd-success); + box-shadow: 0 0 6px var(--shd-success); + } + .mobile-agent-state strong { + font-size: var(--shd-type-telemetry); + } + .mobile-navigation form { + margin: 0; + padding: 0 14px 14px; + } + .mobile-sign-out { + width: 100%; + min-height: 40px; + border: 1px solid #503b37; + background: #1b1211; + color: var(--shd-danger); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 1px; + cursor: pointer; + } + .mobile-sign-out:hover, + .mobile-sign-out:focus-visible { + border-color: var(--shd-danger); + background: #251715; + } + .mobile-sign-out:active { + transform: translateY(1px); + filter: brightness(0.86); + } +} +@media (max-width: 430px) { + .mobile-nav-layer { + grid-template-columns: 36px 1fr; + } +} +@media (prefers-reduced-motion: reduce) { + .mobile-nav-toggle > span { + transition: none; + } +} +@media (max-width: 700px) { + .module-toolbar { + align-items: stretch; + flex-direction: column; + gap: 11px; + padding-bottom: 11px; + } + .module-toolbar > div:first-child { + width: 100%; + } + .module-toolbar > button { + width: 100%; + min-height: 48px; + justify-content: center; + } + .module-toolbar > div:last-child:has(button) { + width: 100%; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); + gap: 7px; + } + .module-toolbar > div:last-child:has(button) > button { + width: 100%; + min-height: 48px; + padding: 0 14px; + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; + } + .module-toolbar .primary, + .module-toolbar .secondary { + height: auto; + min-height: 48px; + } +} /* Gear shared Tactical Augment profiles */ -.gear-augment-registry{margin:10px 0;border:1px solid var(--shd-border);background:#0d1011}.gear-augment-registry>summary{margin:0;padding:9px 10px;cursor:pointer;list-style:none}.gear-augment-registry>summary::-webkit-details-marker{display:none}.gear-augment-registry>summary:hover,.gear-augment-registry>summary:focus-visible{background:#171b1c}.gear-augment-registry>summary:active{transform:translateY(1px);filter:brightness(.88)}.gear-augment-registry>summary>div:last-child{display:flex;align-items:center;gap:10px}.gear-augment-registry>summary i{color:var(--shd-orange);font-size:13px;font-style:normal;transition:transform .16s ease}.gear-augment-registry[open]>summary{border-bottom:1px solid var(--shd-border);background:#131718}.gear-augment-registry[open]>summary i{transform:rotate(45deg)}.gear-augment-profile-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px;padding:8px}.gear-augment-profile-grid>button{min-width:0;padding:8px;border:1px solid var(--shd-border);background:#111415;color:var(--shd-text-muted);text-align:left;cursor:pointer}.gear-augment-profile-grid>button:hover,.gear-augment-profile-grid>button:focus-visible{border-color:#8a672b;background:#171b1c}.gear-augment-profile-grid>button:active{transform:translateY(1px);filter:brightness(.88)}.gear-augment-profile-grid>button.configured{border-left:2px solid var(--shd-success)}.gear-augment-profile-grid header{display:flex;justify-content:space-between;align-items:center;padding-bottom:6px;border-bottom:1px solid #292e2f}.gear-augment-profile-grid header div{display:grid;gap:2px}.gear-augment-profile-grid header strong{color:#d7d9d5;font-size:var(--shd-type-telemetry)}.gear-augment-profile-grid header small,.gear-augment-profile-grid header>span{color:#626a6c;font-size:var(--shd-type-telemetry);letter-spacing:.6px}.gear-augment-profile-grid>button.configured header>span{color:var(--shd-success)}.gear-augment-profile-grid>button>div{display:grid;margin-top:5px}.gear-augment-profile-grid>button>div span{display:grid;grid-template-columns:22px minmax(0,1fr) auto;gap:6px;padding:5px 2px;border-top:1px solid #232829}.gear-augment-profile-grid>button>div span:first-child{border-top:0}.gear-augment-profile-grid>button>div small{color:var(--shd-orange);font-size:var(--shd-type-telemetry)}.gear-augment-profile-grid>button>div b{overflow:hidden;color:#aeb4b5;font-size:var(--shd-type-telemetry);text-overflow:ellipsis;white-space:nowrap}.gear-augment-profile-grid>button>div em{color:#626a6c;font-size:var(--shd-type-telemetry);font-style:normal}.gear-augment-editor{border-top-color:var(--shd-success)}.gear-augment-slots{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:7px}.gear-augment-slots section{min-width:0;padding:8px;border:1px solid var(--shd-border);background:#101314}.gear-augment-slots section>header{display:flex;justify-content:space-between;padding-bottom:6px;border-bottom:1px solid #2a2f30}.gear-augment-slots section>header strong{color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.gear-augment-slots section>header span{color:#596164;font-size:var(--shd-type-telemetry)}.gear-augment-slots .field{margin:7px 0 0}.gear-augment-slots .field>span,.gear-augment-slots label{font-size:var(--shd-type-telemetry)}@media(max-width:1100px){.gear-augment-slots{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:900px){.gear-augment-profile-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:560px){.gear-augment-profile-grid,.gear-augment-slots{grid-template-columns:1fr}.gear-augment-profile-grid>button{min-height:44px}} -.registry-icon-rail{display:flex!important;align-items:center;gap:4px}.registry-icon-rail>span{width:30px;height:30px;display:grid;place-items:center;border:1px solid #353b3c;background:#0d1011}.registry-icon-rail img{width:26px;height:26px;object-fit:contain}.registry-icon-rail i{color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-style:normal;font-weight:900;letter-spacing:.3px}.registry-icon-rail>span:hover{border-color:#8a672b;background:#171b1c} -.gear-fixed-augment{min-height:70px;display:grid;align-content:center;gap:4px;margin-top:7px;padding:8px;border:1px solid #34473a;border-left:2px solid var(--shd-success);background:#101713}.gear-fixed-augment span,.gear-fixed-augment small{color:#718078;font-size:var(--shd-type-telemetry);letter-spacing:.6px}.gear-fixed-augment b{color:var(--shd-success);font-size:17px} -.gear-tier-costs{margin:8px 0 0;padding:7px;border:1px solid #303536}.gear-tier-costs legend{padding:0 5px;color:#697174;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.gear-tier-costs>div{display:grid;grid-template-columns:repeat(3,1fr);gap:5px}.gear-tier-costs .field{margin:0}.gear-augment-profile-grid>button>div em{white-space:nowrap}@media(max-width:560px){.gear-tier-costs>div{grid-template-columns:1fr 1fr 1fr}} -.gear-identity{grid-template-columns:minmax(360px,1fr) 200px minmax(150px,180px);align-items:end}.gear-identity>.field,.gear-identity>.segmented-choice{margin:0}.gear-identity .segmented-choice legend{font-size:var(--shd-type-telemetry)}.gear-identity .segmented-choice>div{margin-top:5px}.gear-identity .segmented-choice:first-of-type>div{grid-template-columns:repeat(6,32px);width:max-content}.gear-identity .segmented-choice:first-of-type label,.gear-identity .segmented-choice:first-of-type span{width:32px}@media(max-width:900px){.gear-identity{grid-template-columns:1fr 1fr}.gear-identity>.field{grid-column:1/3}}@media(max-width:700px){.gear-identity{grid-template-columns:1fr}.gear-identity>.field{grid-column:auto}.gear-identity .segmented-choice>div{height:auto}.gear-identity .segmented-choice span{min-height:44px}.gear-identity .segmented-choice:first-of-type>div{grid-template-columns:repeat(3,44px)}.gear-identity .segmented-choice:first-of-type label,.gear-identity .segmented-choice:first-of-type span{width:44px}} -.builder-field-range{float:right;color:#5f6769;font-size:var(--shd-type-telemetry)}.builder-scaling-note{margin:7px 0 0;padding:7px 9px;border-left:2px solid #667174;background:#101415;color:#727a7c;font-size:var(--shd-type-telemetry);line-height:1.45} -.builder-scaling-note.provisional{border-left-color:var(--quality-high-end);background:#18150d;color:#a89568}.range-value-control{min-width:0;display:grid;gap:6px;padding:8px;border:1px solid var(--shd-border);background:#0d1011}.range-value-head{display:flex;align-items:center;justify-content:space-between;gap:8px}.range-value-head b{color:#7f8789;font-size:var(--shd-type-telemetry);letter-spacing:.7px}.range-value-head small{color:#667072;font-size:var(--shd-type-telemetry);letter-spacing:.4px}.range-value-inputs{display:grid;grid-template-columns:minmax(90px,1fr) 76px;align-items:center;gap:8px}.range-value-slider{width:100%;height:22px!important;margin:0!important;padding:0!important;accent-color:var(--shd-orange);cursor:pointer}.range-value-number{width:100%;height:34px!important;margin:0!important;padding:0 7px!important}.range-value-control:focus-within{border-color:#8a672b;background:#121516}.range-value-slider:active{filter:brightness(.86)}.builder-equipment-controls{display:grid;grid-template-columns:minmax(190px,.7fr) minmax(0,1.3fr);align-items:start;gap:7px;margin-top:8px}.builder-equipment-controls>.builder-radio-grid:last-child:nth-child(3){grid-column:1/3}.builder-equipment-controls>.builder-radio-grid.icon-only{align-self:start;margin:0}.builder-weapon-controls{display:grid;grid-template-columns:minmax(190px,.85fr) minmax(120px,.55fr) minmax(230px,1fr);align-items:end;gap:7px;margin-top:8px}.builder-weapon-controls>.segmented-choice>div{height:50px}.builder-radio-grid{min-width:0;margin:8px 0 0;padding:0;border:0}.builder-radio-grid legend{margin-bottom:5px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.builder-radio-search{height:34px!important;margin:0 0 5px!important;padding:0 9px!important;font-size:var(--shd-type-telemetry)!important}.builder-radio-grid>div{display:grid;grid-template-columns:repeat(var(--builder-radio-columns),minmax(0,1fr));gap:4px}.builder-radio-grid>div>label{min-width:0;cursor:pointer}.builder-radio-grid input[type=radio]{position:absolute;width:1px;height:1px;margin:0;opacity:0;pointer-events:none}.builder-radio-grid label>span{min-height:58px;display:grid;grid-template-columns:34px minmax(0,1fr);grid-template-rows:1fr 1fr;align-items:center;gap:1px 7px;padding:6px;border:1px solid #303536;background:#0d1011;color:#747c7e;transition:var(--shd-transition)}.builder-radio-grid img,.builder-radio-grid i{grid-row:1/3;width:32px;height:32px;object-fit:contain}.builder-radio-grid i{display:grid;place-items:center;border:1px solid #343a3b;color:#9a702e;font-size:var(--shd-type-telemetry);font-style:normal}.builder-radio-grid b{align-self:end;overflow:hidden;color:#b9bfc0;font-size:var(--shd-type-telemetry);line-height:1.25;text-overflow:ellipsis;white-space:nowrap}.builder-radio-grid small{align-self:start;overflow:hidden;color:#626a6c;font-size:var(--shd-type-telemetry);text-overflow:ellipsis;white-space:nowrap}.builder-radio-grid.icon-only>div{width:max-content;max-width:100%;grid-template-columns:repeat(var(--builder-radio-columns),58px)}.builder-radio-grid.icon-only label>span{min-height:0;aspect-ratio:1;display:grid;grid-template-columns:1fr;grid-template-rows:1fr;place-items:center;gap:0;padding:4px}.builder-radio-grid.icon-only img,.builder-radio-grid.icon-only i{grid-column:1;grid-row:1;width:100%;height:100%;max-width:none;max-height:none}.builder-radio-grid.icon-only i{font-size:var(--shd-type-telemetry)}.builder-radio-grid.icon-only b,.builder-radio-grid.icon-only small{display:none}.builder-radio-grid label:hover>span{border-color:#765b2e;background:#16191a}.builder-radio-grid label:active>span{transform:translateY(1px);filter:brightness(.88)}.builder-radio-grid input:checked+span{border-color:#9a6c27;background:#1d180e;color:var(--shd-orange);box-shadow:inset 0 -2px var(--shd-orange)}.builder-radio-grid input:focus-visible+span{outline:2px solid var(--shd-orange);outline-offset:2px}.builder-radio-grid>div>p{grid-column:1/-1;margin:0;padding:12px;border:1px solid var(--shd-border);color:#626a6c;font-size:var(--shd-type-telemetry);text-align:center}.builder-talent-grids{display:grid;gap:3px;margin-top:6px}.builder-rolls.required>div{grid-template-columns:minmax(0,1fr) minmax(180px,.65fr)}@media(max-width:650px){.builder-equipment-controls,.builder-weapon-controls{grid-template-columns:1fr}.builder-equipment-controls>.builder-radio-grid:last-child:nth-child(3),.builder-weapon-controls .builder-fixed{grid-column:auto}.builder-radio-grid>div{grid-template-columns:repeat(2,minmax(0,1fr))}.builder-radio-grid.icon-only>div{grid-template-columns:repeat(var(--builder-radio-columns),58px)}.builder-radio-grid label>span{min-height:62px}.builder-radio-grid.icon-only label>span{min-height:0}.builder-weapon-controls>.segmented-choice>div{height:44px}.range-value-inputs{grid-template-columns:minmax(0,1fr) 84px}}@media(max-width:430px){.builder-radio-grid>div{grid-template-columns:1fr}.builder-radio-grid.icon-only>div{grid-template-columns:repeat(2,58px)}.range-value-inputs{grid-template-columns:1fr}.range-value-number{height:40px!important}} -.builder-core-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:7px}.builder-range-grid.weapon-damage{grid-template-columns:minmax(220px,1fr)}.builder-section-title>b.restricted{color:var(--shd-danger)}.global-configuration-editor{margin-top:10px}.global-configuration-editor .editor-title>b{color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:1px}.configuration-note{margin:10px 0;padding:9px;border-left:2px solid var(--shd-info);background:#101719;color:#8daeb8;font-size:var(--shd-type-telemetry);line-height:1.5}.core-configuration-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.core-configuration-grid fieldset{display:grid;grid-template-columns:64px 1fr 1fr;align-items:end;gap:7px;margin:0;padding:9px;border:1px solid var(--shd-border);background:#0d1011}.core-configuration-grid legend{padding:0 7px;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900}.core-configuration-grid .field{margin:0}.core-config-icons{height:36px;display:flex;align-items:center;justify-content:center}.core-config-icons .game-icon{width:24px;height:24px;margin-left:-5px}.core-config-icons .game-icon:first-child{margin-left:0}.core-configuration-grid small{grid-column:1/-1;color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);letter-spacing:.8px;text-align:right}@media(max-width:850px){.core-configuration-grid{grid-template-columns:1fr}}@media(max-width:650px){.builder-core-grid{grid-template-columns:1fr}} +.gear-augment-registry { + margin: 10px 0; + border: 1px solid var(--shd-border); + background: #0d1011; +} +.gear-augment-registry > summary { + margin: 0; + padding: 9px 10px; + cursor: pointer; + list-style: none; +} +.gear-augment-registry > summary::-webkit-details-marker { + display: none; +} +.gear-augment-registry > summary:hover, +.gear-augment-registry > summary:focus-visible { + background: #171b1c; +} +.gear-augment-registry > summary:active { + transform: translateY(1px); + filter: brightness(0.88); +} +.gear-augment-registry > summary > div:last-child { + display: flex; + align-items: center; + gap: 10px; +} +.gear-augment-registry > summary i { + color: var(--shd-orange); + font-size: 13px; + font-style: normal; + transition: transform 0.16s ease; +} +.gear-augment-registry[open] > summary { + border-bottom: 1px solid var(--shd-border); + background: #131718; +} +.gear-augment-registry[open] > summary i { + transform: rotate(45deg); +} +.gear-augment-profile-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; + padding: 8px; +} +.gear-augment-profile-grid > button { + min-width: 0; + padding: 8px; + border: 1px solid var(--shd-border); + background: #111415; + color: var(--shd-text-muted); + text-align: left; + cursor: pointer; +} +.gear-augment-profile-grid > button:hover, +.gear-augment-profile-grid > button:focus-visible { + border-color: #8a672b; + background: #171b1c; +} +.gear-augment-profile-grid > button:active { + transform: translateY(1px); + filter: brightness(0.88); +} +.gear-augment-profile-grid > button.configured { + border-left: 2px solid var(--shd-success); +} +.gear-augment-profile-grid header { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 6px; + border-bottom: 1px solid #292e2f; +} +.gear-augment-profile-grid header div { + display: grid; + gap: 2px; +} +.gear-augment-profile-grid header strong { + color: #d7d9d5; + font-size: var(--shd-type-telemetry); +} +.gear-augment-profile-grid header small, +.gear-augment-profile-grid header > span { + color: #626a6c; + font-size: var(--shd-type-telemetry); + letter-spacing: 0.6px; +} +.gear-augment-profile-grid > button.configured header > span { + color: var(--shd-success); +} +.gear-augment-profile-grid > button > div { + display: grid; + margin-top: 5px; +} +.gear-augment-profile-grid > button > div span { + display: grid; + grid-template-columns: 22px minmax(0, 1fr) auto; + gap: 6px; + padding: 5px 2px; + border-top: 1px solid #232829; +} +.gear-augment-profile-grid > button > div span:first-child { + border-top: 0; +} +.gear-augment-profile-grid > button > div small { + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); +} +.gear-augment-profile-grid > button > div b { + overflow: hidden; + color: #aeb4b5; + font-size: var(--shd-type-telemetry); + text-overflow: ellipsis; + white-space: nowrap; +} +.gear-augment-profile-grid > button > div em { + color: #626a6c; + font-size: var(--shd-type-telemetry); + font-style: normal; +} +.gear-augment-editor { + border-top-color: var(--shd-success); +} +.gear-augment-slots { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 7px; +} +.gear-augment-slots section { + min-width: 0; + padding: 8px; + border: 1px solid var(--shd-border); + background: #101314; +} +.gear-augment-slots section > header { + display: flex; + justify-content: space-between; + padding-bottom: 6px; + border-bottom: 1px solid #2a2f30; +} +.gear-augment-slots section > header strong { + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); + letter-spacing: 0.8px; +} +.gear-augment-slots section > header span { + color: #596164; + font-size: var(--shd-type-telemetry); +} +.gear-augment-slots .field { + margin: 7px 0 0; +} +.gear-augment-slots .field > span, +.gear-augment-slots label { + font-size: var(--shd-type-telemetry); +} +@media (max-width: 1100px) { + .gear-augment-slots { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} +@media (max-width: 900px) { + .gear-augment-profile-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} +@media (max-width: 560px) { + .gear-augment-profile-grid, + .gear-augment-slots { + grid-template-columns: 1fr; + } + .gear-augment-profile-grid > button { + min-height: 44px; + } +} +.registry-icon-rail { + display: flex !important; + align-items: center; + gap: 4px; +} +.registry-icon-rail > span { + width: 30px; + height: 30px; + display: grid; + place-items: center; + border: 1px solid #353b3c; + background: #0d1011; +} +.registry-icon-rail img { + width: 26px; + height: 26px; + object-fit: contain; +} +.registry-icon-rail i { + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); + font-style: normal; + font-weight: 900; + letter-spacing: 0.3px; +} +.registry-icon-rail > span:hover { + border-color: #8a672b; + background: #171b1c; +} +.gear-fixed-augment { + min-height: 70px; + display: grid; + align-content: center; + gap: 4px; + margin-top: 7px; + padding: 8px; + border: 1px solid #34473a; + border-left: 2px solid var(--shd-success); + background: #101713; +} +.gear-fixed-augment span, +.gear-fixed-augment small { + color: #718078; + font-size: var(--shd-type-telemetry); + letter-spacing: 0.6px; +} +.gear-fixed-augment b { + color: var(--shd-success); + font-size: 17px; +} +.gear-tier-costs { + margin: 8px 0 0; + padding: 7px; + border: 1px solid #303536; +} +.gear-tier-costs legend { + padding: 0 5px; + color: #697174; + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.7px; +} +.gear-tier-costs > div { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 5px; +} +.gear-tier-costs .field { + margin: 0; +} +.gear-augment-profile-grid > button > div em { + white-space: nowrap; +} +@media (max-width: 560px) { + .gear-tier-costs > div { + grid-template-columns: 1fr 1fr 1fr; + } +} +.gear-identity { + grid-template-columns: minmax(360px, 1fr) 200px minmax(150px, 180px); + align-items: end; +} +.gear-identity > .field, +.gear-identity > .segmented-choice { + margin: 0; +} +.gear-identity .segmented-choice legend { + font-size: var(--shd-type-telemetry); +} +.gear-identity .segmented-choice > div { + margin-top: 5px; +} +.gear-identity .segmented-choice:first-of-type > div { + grid-template-columns: repeat(6, 32px); + width: max-content; +} +.gear-identity .segmented-choice:first-of-type label, +.gear-identity .segmented-choice:first-of-type span { + width: 32px; +} +@media (max-width: 900px) { + .gear-identity { + grid-template-columns: 1fr 1fr; + } + .gear-identity > .field { + grid-column: 1/3; + } +} +@media (max-width: 700px) { + .gear-identity { + grid-template-columns: 1fr; + } + .gear-identity > .field { + grid-column: auto; + } + .gear-identity .segmented-choice > div { + height: auto; + } + .gear-identity .segmented-choice span { + min-height: 44px; + } + .gear-identity .segmented-choice:first-of-type > div { + grid-template-columns: repeat(3, 44px); + } + .gear-identity .segmented-choice:first-of-type label, + .gear-identity .segmented-choice:first-of-type span { + width: 44px; + } +} +.builder-field-range { + float: right; + color: #5f6769; + font-size: var(--shd-type-telemetry); +} +.builder-scaling-note { + margin: 7px 0 0; + padding: 7px 9px; + border-left: 2px solid #667174; + background: #101415; + color: #727a7c; + font-size: var(--shd-type-telemetry); + line-height: 1.45; +} +.builder-scaling-note.provisional { + border-left-color: var(--quality-high-end); + background: #18150d; + color: #a89568; +} +.range-value-control { + min-width: 0; + display: grid; + gap: 6px; + padding: 8px; + border: 1px solid var(--shd-border); + background: #0d1011; +} +.range-value-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.range-value-head b { + color: #7f8789; + font-size: var(--shd-type-telemetry); + letter-spacing: 0.7px; +} +.range-value-head small { + color: #667072; + font-size: var(--shd-type-telemetry); + letter-spacing: 0.4px; +} +.range-value-inputs { + display: grid; + grid-template-columns: minmax(90px, 1fr) 76px; + align-items: center; + gap: 8px; +} +.range-value-slider { + width: 100%; + height: 22px !important; + margin: 0 !important; + padding: 0 !important; + accent-color: var(--shd-orange); + cursor: pointer; +} +.range-value-number { + width: 100%; + height: 34px !important; + margin: 0 !important; + padding: 0 7px !important; +} +.range-value-control:focus-within { + border-color: #8a672b; + background: #121516; +} +.range-value-slider:active { + filter: brightness(0.86); +} +.builder-equipment-controls { + display: grid; + grid-template-columns: minmax(190px, 0.7fr) minmax(0, 1.3fr); + align-items: start; + gap: 7px; + margin-top: 8px; +} +.builder-equipment-controls > .builder-radio-grid:last-child:nth-child(3) { + grid-column: 1/3; +} +.builder-equipment-controls > .builder-radio-grid.icon-only { + align-self: start; + margin: 0; +} +.builder-weapon-controls { + display: grid; + grid-template-columns: minmax(190px, 0.85fr) minmax(120px, 0.55fr) minmax( + 230px, + 1fr + ); + align-items: end; + gap: 7px; + margin-top: 8px; +} +.builder-weapon-controls > .segmented-choice > div { + height: 50px; +} +.builder-radio-grid { + min-width: 0; + margin: 8px 0 0; + padding: 0; + border: 0; +} +.builder-radio-grid legend { + margin-bottom: 5px; + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.8px; +} +.builder-radio-search { + height: 34px !important; + margin: 0 0 5px !important; + padding: 0 9px !important; + font-size: var(--shd-type-telemetry) !important; +} +.builder-radio-grid > div { + display: grid; + grid-template-columns: repeat(var(--builder-radio-columns), minmax(0, 1fr)); + gap: 4px; +} +.builder-radio-grid > div > label { + min-width: 0; + cursor: pointer; +} +.builder-radio-grid input[type='radio'] { + position: absolute; + width: 1px; + height: 1px; + margin: 0; + opacity: 0; + pointer-events: none; +} +.builder-radio-grid label > span { + min-height: 58px; + display: grid; + grid-template-columns: 34px minmax(0, 1fr); + grid-template-rows: 1fr 1fr; + align-items: center; + gap: 1px 7px; + padding: 6px; + border: 1px solid #303536; + background: #0d1011; + color: #747c7e; + transition: var(--shd-transition); +} +.builder-radio-grid img, +.builder-radio-grid i { + grid-row: 1/3; + width: 32px; + height: 32px; + object-fit: contain; +} +.builder-radio-grid i { + display: grid; + place-items: center; + border: 1px solid #343a3b; + color: #9a702e; + font-size: var(--shd-type-telemetry); + font-style: normal; +} +.builder-radio-grid b { + align-self: end; + overflow: hidden; + color: #b9bfc0; + font-size: var(--shd-type-telemetry); + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} +.builder-radio-grid small { + align-self: start; + overflow: hidden; + color: #626a6c; + font-size: var(--shd-type-telemetry); + text-overflow: ellipsis; + white-space: nowrap; +} +.builder-radio-grid.icon-only > div { + width: max-content; + max-width: 100%; + grid-template-columns: repeat(var(--builder-radio-columns), 58px); +} +.builder-radio-grid.icon-only label > span { + min-height: 0; + aspect-ratio: 1; + display: grid; + grid-template-columns: 1fr; + grid-template-rows: 1fr; + place-items: center; + gap: 0; + padding: 4px; +} +.builder-radio-grid.icon-only img, +.builder-radio-grid.icon-only i { + grid-column: 1; + grid-row: 1; + width: 100%; + height: 100%; + max-width: none; + max-height: none; +} +.builder-radio-grid.icon-only i { + font-size: var(--shd-type-telemetry); +} +.builder-radio-grid.icon-only b, +.builder-radio-grid.icon-only small { + display: none; +} +.builder-radio-grid label:hover > span { + border-color: #765b2e; + background: #16191a; +} +.builder-radio-grid label:active > span { + transform: translateY(1px); + filter: brightness(0.88); +} +.builder-radio-grid input:checked + span { + border-color: #9a6c27; + background: #1d180e; + color: var(--shd-orange); + box-shadow: inset 0 -2px var(--shd-orange); +} +.builder-radio-grid input:focus-visible + span { + outline: 2px solid var(--shd-orange); + outline-offset: 2px; +} +.builder-radio-grid > div > p { + grid-column: 1/-1; + margin: 0; + padding: 12px; + border: 1px solid var(--shd-border); + color: #626a6c; + font-size: var(--shd-type-telemetry); + text-align: center; +} +.builder-talent-grids { + display: grid; + gap: 3px; + margin-top: 6px; +} +.builder-rolls.required > div { + grid-template-columns: minmax(0, 1fr) minmax(180px, 0.65fr); +} +@media (max-width: 650px) { + .builder-equipment-controls, + .builder-weapon-controls { + grid-template-columns: 1fr; + } + .builder-equipment-controls > .builder-radio-grid:last-child:nth-child(3), + .builder-weapon-controls .builder-fixed { + grid-column: auto; + } + .builder-radio-grid > div { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .builder-radio-grid.icon-only > div { + grid-template-columns: repeat(var(--builder-radio-columns), 58px); + } + .builder-radio-grid label > span { + min-height: 62px; + } + .builder-radio-grid.icon-only label > span { + min-height: 0; + } + .builder-weapon-controls > .segmented-choice > div { + height: 44px; + } + .range-value-inputs { + grid-template-columns: minmax(0, 1fr) 84px; + } +} +@media (max-width: 430px) { + .builder-radio-grid > div { + grid-template-columns: 1fr; + } + .builder-radio-grid.icon-only > div { + grid-template-columns: repeat(2, 58px); + } + .range-value-inputs { + grid-template-columns: 1fr; + } + .range-value-number { + height: 40px !important; + } +} +.builder-core-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 7px; +} +.builder-range-grid.weapon-damage { + grid-template-columns: minmax(220px, 1fr); +} +.builder-section-title > b.restricted { + color: var(--shd-danger); +} +.global-configuration-editor { + margin-top: 10px; +} +.global-configuration-editor .editor-title > b { + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; +} +.configuration-note { + margin: 10px 0; + padding: 9px; + border-left: 2px solid var(--shd-info); + background: #101719; + color: #8daeb8; + font-size: var(--shd-type-telemetry); + line-height: 1.5; +} +.core-configuration-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} +.core-configuration-grid fieldset { + display: grid; + grid-template-columns: 64px 1fr 1fr; + align-items: end; + gap: 7px; + margin: 0; + padding: 9px; + border: 1px solid var(--shd-border); + background: #0d1011; +} +.core-configuration-grid legend { + padding: 0 7px; + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); + font-weight: 900; +} +.core-configuration-grid .field { + margin: 0; +} +.core-config-icons { + height: 36px; + display: flex; + align-items: center; + justify-content: center; +} +.core-config-icons .game-icon { + width: 24px; + height: 24px; + margin-left: -5px; +} +.core-config-icons .game-icon:first-child { + margin-left: 0; +} +.core-configuration-grid small { + grid-column: 1/-1; + color: var(--shd-text-dim); + font-size: var(--shd-type-telemetry); + letter-spacing: 0.8px; + text-align: right; +} +@media (max-width: 850px) { + .core-configuration-grid { + grid-template-columns: 1fr; + } +} +@media (max-width: 650px) { + .builder-core-grid { + grid-template-columns: 1fr; + } +} /* Builder workbench */ -.loadout-workspace{display:grid;grid-template-columns:minmax(230px,280px) minmax(420px,1fr) minmax(290px,350px);gap:10px;align-items:start}.loadout-slots,.loadout-editor,.loadout-summary{border:1px solid var(--shd-border);background:#111415}.loadout-slots>header,.summary-section>header{display:flex;justify-content:space-between;align-items:center;padding:9px 10px;border-bottom:1px solid var(--shd-border);background:#0d1011;color:#6d7577;font-size:var(--shd-type-telemetry);letter-spacing:1px}.loadout-slots>header b,.summary-section>header b{color:var(--shd-orange)}.loadout-slot-group{display:grid;gap:4px;padding:5px;border-bottom:1px solid #292e2f}.loadout-slot-group.weapons{grid-template-columns:1fr 1fr}.loadout-slot-group.gear{grid-template-columns:1fr 1fr}.loadout-slot-group.systems{grid-template-columns:1fr 1fr}.loadout-slot-group button{min-width:0;min-height:65px;display:grid;align-content:center;gap:3px;padding:8px;border:1px solid #2b3031;border-left:2px solid #3b4142;background:#101314;color:#c5c9ca;text-align:left;cursor:pointer}.loadout-slot-group button:hover,.loadout-slot-group button:focus-visible{border-color:#8b682b;background:#171b1c}.loadout-slot-group button:active{transform:translateY(1px);filter:brightness(.88)}.loadout-slot-group button.active{border-left-color:var(--shd-orange);background:#1b170f}.loadout-slot-group small{color:#646c6e;font-size:var(--shd-type-telemetry);letter-spacing:.8px}.loadout-slot-group strong{overflow:hidden;font-size:var(--shd-type-telemetry);text-overflow:ellipsis;white-space:nowrap}.loadout-slot-group span{overflow:hidden;color:#7c8486;font-size:var(--shd-type-telemetry);text-overflow:ellipsis;white-space:nowrap}.loadout-editor{min-height:680px;padding:12px;border-top:2px solid var(--shd-orange)}.loadout-editor-head{display:flex;justify-content:space-between;align-items:end;padding-bottom:9px;border-bottom:1px solid #303536}.loadout-editor-head span{color:#687072;font-size:var(--shd-type-telemetry);letter-spacing:1px}.loadout-editor-head h2{margin:3px 0 0;font-size:19px}.loadout-editor-head>b{color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.builder-picker{position:relative;margin-top:9px}.builder-picker>span{display:block;margin-bottom:5px;color:#737b7d;font-size:var(--shd-type-telemetry);font-weight:800;letter-spacing:.8px}.builder-picker-current{width:100%;min-height:48px;display:grid;grid-template-columns:34px minmax(0,1fr) auto;grid-template-rows:1fr 1fr;align-items:center;column-gap:8px;padding:6px 9px;border:1px solid #343a3b;background:#0e1112;color:#d7d9d5;text-align:left}.builder-picker-current img,.builder-picker-current>i,.builder-picker-panel button img,.builder-picker-panel button>i{grid-row:1/3;width:30px;height:30px;object-fit:contain}.builder-picker-current>i,.builder-picker-panel button>i{display:grid;place-items:center;color:var(--shd-orange);font-style:normal}.builder-picker-current b{align-self:end;font-size:var(--shd-type-telemetry)}.builder-picker-current small{align-self:start;color:#666e70;font-size:var(--shd-type-telemetry)}.builder-picker-current em{grid-column:3;grid-row:1/3;color:var(--shd-orange);font-size:14px;font-style:normal}.builder-picker-panel{position:absolute;z-index:30;top:100%;left:0;right:0;padding:6px;border:1px solid #5a4a2d;background:#0b0e0f;box-shadow:0 12px 25px #000b}.builder-picker-panel>input{height:36px;margin:0 0 5px;padding:0 9px;font-size:var(--shd-type-telemetry)}.builder-picker-panel>div{max-height:250px;overflow:auto}.builder-picker-panel>div>button{width:100%;min-height:42px;display:grid;grid-template-columns:30px minmax(0,1fr) auto;align-items:center;gap:7px;padding:5px 7px;border:0;border-top:1px solid #272c2d;background:#111415;color:#c4c8c9;text-align:left}.builder-picker-panel>div>button:hover,.builder-picker-panel>div>button.selected{background:#1b170f}.builder-picker-panel>div>button:disabled{opacity:.35}.builder-picker-panel button span{display:grid;gap:2px}.builder-picker-panel button b{font-size:var(--shd-type-telemetry)}.builder-picker-panel button small,.builder-picker-panel button em{color:#687072;font-size:var(--shd-type-telemetry);font-style:normal}.builder-picker-panel button.selected em{color:var(--shd-orange)}.builder-picker-panel p{padding:12px;color:#626a6c;font-size:var(--shd-type-telemetry)}.builder-field-grid,.builder-range-grid{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin-top:8px}.builder-field-grid>label,.builder-range-grid>label,.builder-chip-rolls label,.builder-rolls label{color:#737b7d;font-size:var(--shd-type-telemetry);font-weight:800;letter-spacing:.7px}.builder-field-grid select,.builder-range-grid input,.builder-chip-rolls input,.builder-rolls input{height:38px;margin-top:5px;padding:0 8px;font-size:var(--shd-type-telemetry)}.builder-range-grid label>span{float:right;color:#5f6769;font-size:var(--shd-type-telemetry)}.builder-range-grid.weapon{grid-template-columns:repeat(2,1fr)}.builder-fixed{display:flex;align-items:end;gap:5px;padding-bottom:1px}.builder-fixed span{flex:1;padding:9px 5px;border:1px solid #303536;color:#8e9698;font-size:var(--shd-type-telemetry);text-align:center}.builder-section-title{display:flex;justify-content:space-between;align-items:end;margin:13px 0 5px}.builder-section-title>div{display:grid;gap:3px}.builder-section-title span{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.builder-section-title small{color:#626a6c;font-size:var(--shd-type-telemetry)}.builder-section-title button{border:0;background:transparent;color:var(--shd-orange);font-size:var(--shd-type-telemetry)}.builder-section-title>b{color:var(--shd-orange);font-size:var(--shd-type-telemetry)}.builder-rolls{display:grid;gap:5px}.builder-rolls>div,.builder-chip-rolls>div{display:grid;grid-template-columns:minmax(0,1fr) 105px 28px;align-items:end;gap:6px;padding:6px;border:1px solid #2d3233;background:#0f1213}.builder-rolls .builder-picker,.builder-chip-rolls .builder-picker{margin:0}.builder-rolls>div>button{height:38px;border:1px solid #563734;background:#1d1212;color:#da746c}.builder-chip-rolls{display:grid;gap:6px;margin-top:8px}.builder-chip-rolls>div{grid-template-columns:minmax(0,1fr) 105px}.builder-augment-list{display:grid;grid-template-columns:1fr 1fr;gap:5px}.builder-augment-list label{min-height:45px;display:grid;grid-template-columns:18px 1fr auto;align-items:center;gap:7px;padding:7px;border:1px solid #303536;background:#0f1213;cursor:pointer}.builder-augment-list input{width:auto;margin:0;accent-color:var(--shd-orange)}.builder-augment-list span{display:grid;gap:3px}.builder-augment-list b{font-size:var(--shd-type-telemetry)}.builder-augment-list small{color:#687072;font-size:var(--shd-type-telemetry)}.builder-augment-list em{color:#a77b31;font-size:var(--shd-type-telemetry);font-style:normal}.builder-inherited{display:grid;grid-template-columns:repeat(3,1fr);margin-top:8px;border:1px solid #303536}.builder-inherited span{display:grid;gap:4px;padding:8px;border-left:1px solid #303536}.builder-inherited span:first-child{border-left:0}.builder-inherited small{color:#687072;font-size:var(--shd-type-telemetry)}.builder-inherited b{color:var(--shd-orange);font-size:var(--shd-type-telemetry)}.loadout-summary{position:sticky;top:88px;border-top:2px solid var(--shd-orange)}.summary-head{display:flex;justify-content:space-between;align-items:end;padding:12px}.summary-head h2{margin:4px 0 0;font-size:20px}.summary-head>b{color:var(--shd-orange);font-size:22px}.summary-core{display:grid;grid-template-columns:repeat(3,1fr);border-top:1px solid #303536;border-bottom:1px solid #303536}.summary-core>div{display:flex;align-items:center;gap:5px;padding:9px 6px;border-left:1px solid #303536}.summary-core>div:first-child{border-left:0}.summary-core .game-icon{width:22px;height:22px}.summary-core span{display:grid;color:#687072;font-size:var(--shd-type-telemetry)}.summary-core b{color:#d6d9da;font-size:var(--shd-type-telemetry)}.summary-scroll{max-height:calc(100vh - 340px);overflow:auto}.summary-section>header{position:sticky;top:0;z-index:2}.summary-stat,.summary-bonus{display:flex;justify-content:space-between;gap:8px;padding:8px 10px;border-top:1px solid #252a2b}.summary-stat>span,.summary-bonus>span{display:grid;gap:3px;font-size:var(--shd-type-telemetry)}.summary-stat small,.summary-bonus small{color:#626a6c;font-size:var(--shd-type-telemetry)}.summary-stat>b{color:#d9dbdc;font-size:var(--shd-type-telemetry)}.summary-bonus em{color:var(--shd-success);font-size:var(--shd-type-telemetry);font-style:normal;text-align:right}.summary-section>p{margin:0;padding:12px;color:#626a6c;font-size:var(--shd-type-telemetry);line-height:1.5}.summary-talent{display:grid;grid-template-columns:18px 1fr;gap:7px;padding:9px 10px;border-top:1px solid #252a2b;cursor:pointer}.summary-talent input{width:auto;margin:2px 0 0;accent-color:var(--shd-orange)}.summary-talent span{display:grid;gap:3px}.summary-talent b{font-size:var(--shd-type-telemetry)}.summary-talent small{color:#8a682f;font-size:var(--shd-type-telemetry)}.summary-talent em{color:#727a7c;font:var(--shd-type-telemetry)/1.45 var(--shd-font-editorial)}.loadout-summary .notice{margin:0;padding:11px;border-top:1px solid #303536;border-left:0}.builder-empty-data{display:flex;gap:12px;margin-bottom:10px;padding:12px;border:1px solid #65403a;border-left:3px solid var(--shd-danger);background:#1c1111}.builder-empty-data strong{font-size:var(--shd-type-telemetry)}.builder-empty-data span{color:#a87870;font-size:var(--shd-type-telemetry)}@media(max-width:1200px){.loadout-workspace{grid-template-columns:240px minmax(390px,1fr)}.loadout-summary{position:static;grid-column:1/3}.summary-scroll{max-height:none;display:grid;grid-template-columns:repeat(3,1fr)}.summary-section{border-left:1px solid #303536}.summary-section:first-child{border-left:0}}@media(max-width:800px){.loadout-workspace{grid-template-columns:1fr}.loadout-slots,.loadout-editor,.loadout-summary{grid-column:auto}.loadout-editor{min-height:0}.summary-scroll{display:block}.summary-section{border-left:0}.loadout-summary{order:3}.loadout-slot-group.gear{grid-template-columns:repeat(3,1fr)}}@media(max-width:560px){.loadout-slot-group.gear,.loadout-slot-group.systems{grid-template-columns:1fr 1fr}.builder-field-grid,.builder-range-grid,.builder-range-grid.weapon,.builder-augment-list,.builder-inherited{grid-template-columns:1fr}.builder-inherited span{border-left:0;border-top:1px solid #303536}.builder-rolls>div,.builder-chip-rolls>div{grid-template-columns:1fr}.builder-rolls>div>button{min-height:44px}.builder-picker-current{min-height:52px}.builder-picker-panel>div>button{min-height:46px}} -.mobile-row-open{display:none}@media(max-width:700px){.mobile-detail-row{position:relative;min-height:48px;padding-bottom:25px;cursor:pointer}.dashboard-row>.mobile-row-open{position:absolute;z-index:2;inset:0;width:100%;height:100%;display:block;justify-self:stretch;margin:0;padding:0;border:0;background:transparent;color:transparent;font-size:0;cursor:pointer}.mobile-detail-row>.row-action,.mobile-detail-row>button:not(.mobile-row-open){display:none}.mobile-detail-row::after{content:"TAP TO VIEW ›";position:absolute;right:10px;bottom:7px;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px;pointer-events:none}.moderation-row.mobile-detail-row::after{content:"TAP TO REVIEW ›"}.mobile-detail-row:hover{border-color:#665026;background:#171713}.mobile-detail-row:has(.mobile-row-open:focus-visible){outline:2px solid var(--shd-orange);outline-offset:-2px}.mobile-detail-row:has(.mobile-row-open:active){transform:translateY(1px);filter:brightness(.86)}}@media(prefers-reduced-motion:reduce){.mobile-detail-row:has(.mobile-row-open:active){transform:none}} -.avatar-review-filter{grid-template-columns:minmax(220px,1fr) auto}.avatar-review-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:7px}.avatar-review-card{min-width:0;display:grid;grid-template-columns:112px minmax(0,1fr);border:1px solid var(--shd-border);background:#111415}.avatar-review-image{position:relative;min-height:142px;display:grid;place-items:center;overflow:hidden;border-right:1px solid var(--shd-border);background:#0b0e0f}.avatar-review-image img{width:100%;height:100%;object-fit:cover}.avatar-review-image>span{padding:10px;color:#596164;font-size:var(--shd-type-telemetry);font-weight:900;line-height:1.5;text-align:center}.avatar-review-image>b{position:absolute;right:0;bottom:0;padding:5px 7px;border-top:1px solid currentColor;border-left:1px solid currentColor;background:#171b1ce8;color:#889092;font-size:var(--shd-type-telemetry);letter-spacing:.7px}.avatar-review-image>b.pending{color:var(--shd-orange)}.avatar-review-image>b.approved{color:var(--shd-success)}.avatar-review-image>b.declined{color:var(--shd-danger)}.avatar-review-details{min-width:0;display:grid;align-content:start;gap:4px;padding:9px}.avatar-review-details>strong{font-size:var(--shd-type-telemetry)}.avatar-review-details>small{overflow:hidden;color:#697174;font-size:var(--shd-type-telemetry);text-overflow:ellipsis;white-space:nowrap}.avatar-review-details dl{display:grid;gap:4px;margin:5px 0}.avatar-review-details dl>div{display:flex;justify-content:space-between;gap:8px;border-top:1px solid #292e2f;padding-top:4px}.avatar-review-details dt,.avatar-review-details dd{margin:0;font-size:var(--shd-type-telemetry)}.avatar-review-details dt{color:#697174}.avatar-review-details dd{color:#b9bebe;text-align:right}.avatar-review-details>p{margin:2px 0;color:#8c9495;font-size:var(--shd-type-telemetry);line-height:1.4}.avatar-review-details>button{min-height:31px;margin-top:auto}.avatar-review-modal{width:min(680px,calc(100vw - 24px))}.avatar-review-preview{height:min(46vh,390px);display:grid;place-items:center;margin:9px 0;overflow:hidden;border:1px solid var(--shd-border);background:#090b0c}.avatar-review-preview img{width:100%;height:100%;object-fit:contain}.avatar-review-preview span{color:#697174;font-size:var(--shd-type-telemetry);letter-spacing:.8px}.avatar-decision-tabs{grid-template-columns:repeat(2,1fr)}@media(max-width:1150px){.avatar-review-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:700px){.avatar-review-filter,.avatar-review-grid{grid-template-columns:1fr}.avatar-review-card{grid-template-columns:96px minmax(0,1fr)}.avatar-review-image{min-height:132px}.avatar-review-details>button{min-height:44px}.avatar-decision-tabs{grid-template-columns:1fr}} -.assignment-chips>button span,.assignment-results>button>span{display:grid;gap:2px}.assignment-chips>button span small,.assignment-results>button>span small{color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.55px} -.assignment-chips>button span .assignment-item-unit,.assignment-results>button>span .assignment-item-unit{color:var(--shd-text-muted);font-weight:700;letter-spacing:.45px} -.notification-link{position:relative;width:34px;height:34px;display:grid!important;place-items:center;border:1px solid var(--shd-border);color:var(--shd-orange)!important}.notification-link>span{font-size:var(--shd-type-telemetry)}.notification-link>b{position:absolute;top:-5px;right:-5px;min-width:17px;height:17px;display:grid;place-items:center;padding:0 4px;border:1px solid #d98921;background:var(--shd-orange);color:#111;font-size:var(--shd-type-telemetry)}.mobile-navigation>a>em{min-width:20px;height:20px;display:grid;place-items:center;padding:0 4px;background:var(--shd-orange);color:#111;font-size:var(--shd-type-telemetry);font-style:normal}.notification-page{max-width:1480px;margin:auto;padding:36px clamp(14px,4vw,56px) 80px}.notification-command{display:flex;align-items:end;justify-content:space-between;padding-bottom:18px;border-bottom:1px solid var(--shd-border)}.notification-command h1{margin:5px 0;font-size:48px}.notification-command p{margin:0;color:var(--shd-text-muted)}.notification-command>div:last-child{display:grid;grid-template-columns:auto auto;align-items:center;gap:2px 8px}.notification-command>div:last-child strong{grid-row:1/3;color:var(--shd-orange);font-size:32px}.notification-command>div:last-child span{font-size:var(--shd-type-telemetry);letter-spacing:1px}.notification-command>div:last-child button{grid-column:1/3;margin-top:6px}.notification-settings{margin:10px 0;padding:10px;border:1px solid #625025;border-top:2px solid var(--shd-orange);background:#111415}.notification-settings .module-head{padding-bottom:8px}.notification-settings .module-head>span{color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.notification-rule{margin:6px 0;padding:8px;border-left:2px solid var(--shd-orange);background:#18150f;color:#8f887a;font-size:var(--shd-type-telemetry);line-height:1.5}.notification-rule b{color:#d9bc82}.browser-permission{margin:4px 0 8px}.notification-preference-form{display:grid}.notification-preference-head,.notification-preference-row{display:grid;grid-template-columns:minmax(190px,1fr) 100px 100px 150px;align-items:center;gap:7px;padding:7px 9px;border-top:1px solid var(--shd-border)}.notification-preference-head{background:#0d1011;color:#697174;font-size:var(--shd-type-telemetry);letter-spacing:1px}.notification-preference-row>div{display:grid;gap:3px}.notification-preference-row>div b{font-size:var(--shd-type-telemetry)}.notification-preference-row>div small{color:#697174;font-size:var(--shd-type-telemetry)}.notification-preference-row label{display:flex;align-items:center;gap:5px;color:#8a9293;font-size:var(--shd-type-telemetry);cursor:pointer}.notification-preference-row input{width:auto;margin:0;accent-color:var(--shd-orange)}.notification-preference-row select{height:31px;margin:0;font-size:var(--shd-type-telemetry)}.notification-toolbar{display:flex;justify-content:space-between;gap:8px;margin-top:12px}.notification-toolbar form{display:flex;gap:6px}.notification-toolbar select{height:36px;margin:0;font-size:var(--shd-type-telemetry)}.notification-list{display:grid;border:1px solid var(--shd-border)}.notification-list>article{display:grid;grid-template-columns:145px minmax(0,1fr);border-top:1px solid var(--shd-border);background:#101314}.notification-list>article:first-child{border-top:0}.notification-list>article.unread{background:#141719;box-shadow:inset 2px 0 var(--shd-orange)}.notification-list>article.mandatory{box-shadow:inset 2px 0 #68a9c4}.notification-source{display:grid;align-content:start;gap:4px;padding:13px;border-right:1px solid var(--shd-border);background:#0d1011}.notification-source b{color:var(--shd-orange);font-size:var(--shd-type-telemetry);line-height:1.4}.notification-source span{color:#667073;font-size:var(--shd-type-telemetry)}.notification-list>article>div:last-child{padding:12px 14px}.notification-list article header{display:flex;justify-content:space-between;gap:12px}.notification-list h2{font-size:12px}.notification-list time{color:#697174;font-size:var(--shd-type-telemetry)}.notification-list article p{margin:7px 0;color:#9aa1a2;font:var(--shd-type-supporting)/1.55 var(--shd-font-editorial)}.notification-list article footer{display:flex;align-items:center;justify-content:space-between}.notification-list article footer a,.notification-list article footer button,.notification-list article footer span{color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.notification-list article footer button{padding:0;border:0;background:none;cursor:pointer}.notification-list article footer button:hover{color:#fff}.notification-list article footer form{margin-left:auto}@media(max-width:700px){.notification-page{padding-top:22px}.notification-command{align-items:start;flex-direction:column;gap:12px}.notification-command>div:last-child{width:100%}.notification-preference-head{display:none}.notification-preference-row{grid-template-columns:1fr 1fr 1fr}.notification-preference-row>div{grid-column:1/4}.notification-preference-row select{width:100%}.notification-toolbar{align-items:stretch;flex-direction:column}.notification-toolbar form{display:grid;grid-template-columns:1fr 1fr}.notification-toolbar form button{grid-column:1/3;min-height:44px}.notification-toolbar>form:last-child button{width:100%}.notification-list>article{grid-template-columns:1fr}.notification-source{display:flex;justify-content:space-between;border-right:0;border-bottom:1px solid var(--shd-border)}.notification-list article header{display:grid}.notification-list article footer{min-height:36px}} -.avatar-remove-reason{grid-template-columns:1fr;align-content:center}.avatar-remove-reason>form{display:grid;grid-template-columns:minmax(150px,1fr) auto;gap:6px}.avatar-remove-reason>form>input{height:30px;margin:0;padding:0 7px;font-size:var(--shd-type-telemetry)}.avatar-remove-reason>form>div{height:30px;display:grid;grid-template-columns:58px 96px;gap:5px}.avatar-remove-reason>form button{height:30px;min-height:0}@media(max-width:600px){.avatar-remove-reason>form{grid-template-columns:1fr}.avatar-remove-reason>form>div{grid-template-columns:1fr 1fr}} -.page-breadcrumb{display:flex;align-items:center;gap:10px;margin:0}.page-breadcrumb .breadcrumb-line{flex:0 0 28px;width:28px;height:2px;background:var(--shd-orange)}.page-breadcrumb ol{display:flex;align-items:center;gap:0;margin:0;padding:0;list-style:none}.page-breadcrumb li{display:flex;align-items:center}.page-breadcrumb li+li:before{content:"/";margin:0 8px;color:#4b5254}.page-breadcrumb a{color:var(--shd-orange)}.page-breadcrumb [aria-current=page]{color:#9da1a2}@media(max-width:700px){.page-breadcrumb li+li:before{margin:0 6px}} -.page-breadcrumb{align-self:start;width:max-content;height:auto;min-height:14px;line-height:1}.page-breadcrumb .breadcrumb-line{margin:0}.page-breadcrumb ol{height:auto}.page-breadcrumb li{line-height:1}.page-breadcrumb li>span{display:inline;width:auto;height:auto;margin:0;background:transparent;vertical-align:baseline}.notification-message-header>div{display:flex;align-items:center;gap:8px;min-width:0}@media(max-width:700px){.notification-message-header{display:grid!important}.notification-message-header>div{align-items:flex-start;flex-direction:column}} -.notification-severity{flex:0 0 auto;padding:3px 5px;border:1px solid #8a6731;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.6px}.notification-severity.error-severity,.notification-detail-state .error-severity{border-color:#6d3f39;color:var(--shd-danger)} -.system-message-body{display:grid;gap:6px;margin:8px 0 7px;padding:8px 10px;border-left:2px solid #68a9c4;background:linear-gradient(90deg,rgba(49,104,128,.12),rgba(49,104,128,.025))}.system-message-kicker{display:flex;align-items:center;gap:6px;color:#72b8d5}.system-message-kicker span{font-size:var(--shd-type-telemetry)}.system-message-kicker b{font-size:var(--shd-type-telemetry);letter-spacing:1.15px}.system-message-body>p,.notification-list article .system-message-body>p{margin:0;color:#a8b1b3;font:var(--shd-type-supporting)/1.5 var(--shd-font-editorial)}.system-message-detail{display:grid;grid-template-columns:96px minmax(0,1fr);gap:9px;padding-top:6px;border-top:1px solid rgba(112,185,214,.18)}.system-message-detail>span{padding-top:2px;color:#79bcd6;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.9px}.system-message-detail>p,.notification-list article .system-message-detail>p{margin:0;color:#8f999b;font:var(--shd-type-telemetry)/1.5 var(--shd-font-editorial)}.system-message-body.expanded{margin:0;min-height:110px;padding:15px 16px;gap:10px;border-left-color:#68a9c4;background:linear-gradient(90deg,rgba(49,104,128,.16),rgba(14,17,18,.9))}.system-message-body.expanded .system-message-kicker b{font-size:var(--shd-type-telemetry)}.system-message-body.expanded>p{color:#c0c7c8;font-size:13px;line-height:1.7}.system-message-body.expanded .system-message-detail{grid-template-columns:125px minmax(0,1fr);padding-top:10px}.system-message-body.expanded .system-message-detail>span{font-size:var(--shd-type-telemetry)}.system-message-body.expanded .system-message-detail>p{color:#adb6b8;font-size:12px;line-height:1.65}@media(max-width:700px){.system-message-detail,.system-message-body.expanded .system-message-detail{grid-template-columns:1fr;gap:4px}} -.system-message-body:not(.expanded)>p,.system-message-body:not(.expanded) .system-message-detail>p,.notification-body-preview{display:-webkit-box;overflow:hidden;-webkit-box-orient:vertical;-webkit-line-clamp:2}.notification-actions{display:flex;align-items:center;gap:7px}.notification-actions .notification-response-action{min-height:28px;display:inline-flex;align-items:center;padding:0 10px;border:1px solid #6b5329;background:#1b160d;color:var(--shd-orange);transition:var(--shd-transition)}.notification-actions .notification-response-action:hover{border-color:var(--shd-orange);background:#241b0d;color:#fff}.notification-actions .notification-response-action:active{transform:translateY(1px);filter:brightness(.9)}.notification-actions .notification-response-action:focus-visible{outline:2px solid var(--shd-orange);outline-offset:2px}.profile-anchor{position:relative;top:calc(-1 * var(--shd-header-height));visibility:hidden}@media(max-width:700px){.notification-actions{align-items:stretch;flex-direction:column}.notification-actions .notification-response-action{min-height:44px;justify-content:center}} -.notification-filter-console{flex:1;display:grid!important;grid-template-columns:minmax(520px,1fr) minmax(190px,.34fr);align-items:end!important;gap:8px!important}.notification-filter-console>input{display:none}.notification-filter-group{min-width:0;margin:0;padding:0;border:0}.notification-filter-group legend{margin-bottom:5px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1px}.notification-filter-group>div{height:40px;display:grid;grid-template-columns:repeat(6,minmax(0,1fr));border:1px solid #363b3c;background:#0d0f10}.notification-filter-group label{min-width:0;border-left:1px solid var(--shd-border);cursor:pointer}.notification-filter-group label:first-child{border-left:0}.notification-filter-group input{position:absolute;opacity:0;pointer-events:none}.notification-filter-group span{height:100%;display:flex;align-items:center;justify-content:center;gap:5px;color:#777f81;transition:var(--shd-transition)}.notification-filter-group span i{width:5px;height:5px;border:1px solid currentColor;background:transparent}.notification-filter-group b{overflow:hidden;text-overflow:ellipsis;color:inherit;font-size:var(--shd-type-telemetry);letter-spacing:.6px;white-space:nowrap}.notification-filter-group label:hover span{background:#171b1c;color:#c4c8c9}.notification-filter-group label:active span{transform:translateY(1px);filter:brightness(.88)}.notification-filter-group input:checked+span{background:#171b1c;color:var(--shd-orange);box-shadow:inset 0 -2px currentColor}.notification-filter-group input:checked+span i{background:currentColor}.notification-filter-group input:focus-visible+span{outline:2px solid var(--shd-orange);outline-offset:2px}.state-filter-group>div{grid-template-columns:repeat(2,minmax(0,1fr))}@media(max-width:900px){.notification-filter-console{grid-template-columns:1fr}.notification-filter-group>div{height:auto;grid-template-columns:repeat(3,1fr)}.notification-filter-group span{min-height:44px}.state-filter-group>div{grid-template-columns:repeat(2,1fr)}}@media(max-width:560px){.notification-filter-group>div{grid-template-columns:repeat(2,1fr)}} -.segmented-choice{min-width:0;margin:0;padding:0;border:0}.segmented-choice legend{margin-bottom:5px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1px}.segmented-choice>div{height:32px;display:grid;grid-template-columns:repeat(var(--segmented-columns),minmax(0,1fr));border:1px solid #363b3c;background:#0d0f10}.segmented-choice label{min-width:0;border-left:1px solid var(--shd-border);cursor:pointer}.segmented-choice label:first-child{border-left:0}.segmented-choice input{position:absolute;width:1px;height:1px;margin:0;opacity:0;pointer-events:none}.segmented-choice span{height:100%;display:flex;align-items:center;justify-content:center;gap:5px;color:#777f81;transition:var(--shd-transition)}.segmented-choice span i{width:5px;height:5px;border:1px solid currentColor;background:transparent}.segmented-choice .game-icon{width:18px;height:18px;opacity:.55}.segmented-choice b{overflow:hidden;color:inherit;font-size:var(--shd-type-telemetry);letter-spacing:.6px;text-overflow:ellipsis;text-transform:uppercase;white-space:nowrap}.segmented-choice label:hover span{background:#171b1c;color:#c4c8c9}.segmented-choice label:active span{transform:translateY(1px);filter:brightness(.88)}.segmented-choice input:checked+span{background:#171b1c;color:var(--shd-orange);box-shadow:inset 0 -2px currentColor}.segmented-choice input:checked+span i{background:currentColor}.segmented-choice input:checked+span .game-icon{opacity:1}.segmented-choice input:focus-visible+span{outline:2px solid var(--shd-orange);outline-offset:2px}.segmented-choice input:disabled+span{opacity:.45;cursor:not-allowed}.protocol-classification-stack .segmented-choice legend{font-size:var(--shd-type-telemetry)}.protocol-classification-stack .segmented-choice>div{margin-top:5px}.protocol-classification-stack .quality-standard i,.protocol-classification-stack .quality-specialized i,.protocol-classification-stack .quality-superior i,.protocol-classification-stack .quality-highend i{background:currentColor} -.segmented-choice span.icon-only{gap:0}.segmented-choice span.icon-only .game-icon{width:24px;height:24px} -.dashboard-editor .form-grid>.segmented-choice{align-self:end;margin:8px 0}.dashboard-editor .form-grid>.segmented-choice legend,.talent-identity .segmented-choice legend,.weapon-identity-grid .segmented-choice legend{font-size:var(--shd-type-telemetry)}.talent-identity{display:grid;grid-template-columns:minmax(260px,1fr) minmax(430px,1.5fr)!important;align-items:end;gap:9px}.talent-identity>.field{margin:0}.weapon-identity-grid{grid-template-columns:minmax(360px,1fr) 200px minmax(145px,170px) 104px 40px;align-items:end}.weapon-identity-grid>.field,.weapon-identity-grid>.segmented-choice{margin:0}.weapon-identity-grid>.segmented-choice:first-of-type>div{grid-template-columns:repeat(6,32px);width:max-content}.weapon-identity-grid>.segmented-choice:first-of-type label,.weapon-identity-grid>.segmented-choice:first-of-type span{width:32px}.weapon-identity-grid>.segmented-choice:nth-of-type(3)>div{grid-template-columns:repeat(3,32px);width:max-content}.weapon-identity-grid>.segmented-choice:nth-of-type(3) label,.weapon-identity-grid>.segmented-choice:nth-of-type(3) span{width:32px}.exotic-toggle>span{justify-content:center;padding:0;cursor:pointer}.weapon-identity-grid .exotic-toggle input{position:absolute;width:1px;height:1px;margin:0;opacity:0;pointer-events:none}.exotic-toggle .game-icon{width:24px;height:24px;opacity:.55}.exotic-toggle span:has(input:checked){border-color:#875d25;background:#211a0e;color:var(--shd-orange);box-shadow:inset 0 -2px var(--shd-orange)}.exotic-toggle span:has(input:checked) .game-icon{opacity:1}.exotic-toggle span:has(input:focus-visible){outline:2px solid var(--shd-orange);outline-offset:2px}.weapon-category-label{display:flex!important;align-items:center;gap:4px}.weapon-category-label .game-icon{width:20px;height:20px}.weapon-category-label .game-icon:first-child:not(:last-child){width:16px;height:16px}.history-filter{align-items:end;grid-template-columns:minmax(180px,1fr) minmax(250px,.9fr) 80px}.history-filter .segmented-choice>div{height:34px}.audit-filter>.assignment-picker.compact{height:40px}.moderation-filter{grid-template-columns:minmax(220px,1fr) minmax(280px,.9fr) auto}@media(max-width:1000px){.weapon-identity-grid{grid-template-columns:1fr 1fr}.weapon-identity-grid>.weapon-name{grid-column:1/3}}@media(max-width:700px){.talent-identity,.weapon-identity-grid{grid-template-columns:1fr!important}.weapon-identity-grid>.weapon-name{grid-column:auto}.talent-identity .segmented-choice>div,.weapon-identity-grid .segmented-choice>div,.dashboard-editor .form-grid>.segmented-choice>div{height:auto}.talent-identity .segmented-choice span,.weapon-identity-grid .segmented-choice span,.dashboard-editor .form-grid>.segmented-choice span{min-height:44px}.talent-identity .segmented-choice>div{grid-template-columns:repeat(2,1fr)}.weapon-identity-grid>.segmented-choice:first-of-type>div{grid-template-columns:repeat(3,44px)}.weapon-identity-grid>.segmented-choice:first-of-type label,.weapon-identity-grid>.segmented-choice:first-of-type span{width:44px}.weapon-identity-grid>.segmented-choice:nth-of-type(3)>div{grid-template-columns:repeat(3,44px)}.weapon-identity-grid>.segmented-choice:nth-of-type(3) label,.weapon-identity-grid>.segmented-choice:nth-of-type(3) span{width:44px}.exotic-toggle>span{width:44px;min-height:44px}}@media(max-width:650px){.history-filter{grid-template-columns:1fr}.history-filter .segmented-choice>div{height:auto}.history-filter .segmented-choice span{min-height:44px}.moderation-filter{grid-template-columns:1fr}} -.gear-slot-label{display:flex!important;align-items:center;gap:5px}.gear-slot-label .game-icon{width:20px;height:20px} -.notification-filter-console{min-width:0;grid-template-columns:minmax(0,3fr) minmax(180px,1fr)}.notification-filter-console .segmented-choice>div{height:40px}@media(max-width:900px){.notification-toolbar .notification-filter-console{grid-template-columns:1fr}.notification-filter-console .segmented-choice>div{height:auto;grid-template-columns:repeat(3,1fr)}.notification-filter-console .segmented-choice span{min-height:44px}.notification-filter-console .segmented-choice:last-child>div{grid-template-columns:repeat(2,1fr)}}@media(max-width:560px){.notification-filter-console .segmented-choice>div{grid-template-columns:repeat(2,1fr)}} -.skill-chip-editor{border-top-color:var(--quality-superior)}.skill-chip-identity{display:grid;grid-template-columns:var(--isac-icon-editor-width) minmax(360px,1fr) minmax(220px,.55fr);align-items:start;gap:10px}.skill-chip-identity>.field{margin:0}.quality-scope{min-height:62px;display:grid;align-content:center;gap:5px;padding:7px 10px;border:1px solid var(--shd-border);background:#0d1011}.quality-scope>span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1px}.quality-scope>div{display:flex;gap:7px}.quality-scope b{padding:4px 6px;border:1px solid currentColor;font-size:var(--shd-type-telemetry);letter-spacing:.7px}.quality-scope small{color:#626a6c;font-size:var(--shd-type-telemetry)}.skill-chip-bonus,.skill-chip-talent{margin-top:10px;padding:9px;border:1px solid var(--shd-border);background:#0d1011}.skill-chip-bonus{border-left:2px solid var(--quality-superior)}.skill-chip-bonus-grid{display:grid;grid-template-columns:minmax(360px,1fr) minmax(260px,.65fr);gap:8px;margin-top:7px}.skill-chip-range-fields{display:grid;grid-template-columns:1fr 1fr;gap:7px}.skill-chip-range-fields .field{margin:0}.skill-chip-pools{display:grid;grid-template-columns:1fr 1fr;gap:9px;margin-top:10px}.skill-chip-pools>div{min-width:0}.skill-chip-talent{border-left:2px solid var(--quality-high-end)}.skill-chip-talent-identity{display:grid;grid-template-columns:.7fr 1.3fr;gap:8px}.skill-chip-talent-identity textarea{min-height:66px}.skill-chip-preview{display:grid;grid-template-columns:minmax(360px,.9fr) minmax(260px,1.1fr);align-items:end;gap:8px;margin:4px 0 10px}.skill-chip-preview>div{min-height:42px;display:grid;align-content:center;gap:5px;padding:7px 10px;border:1px solid var(--shd-border);background:#111415}.skill-chip-preview>div span{color:var(--quality-high-end);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.skill-chip-preview p{margin:0;color:#c4c9ca;font:var(--shd-type-telemetry)/1.45 var(--shd-font-editorial)}.skill-chip-talent-grid{display:grid;grid-template-columns:1fr 1fr;gap:9px}.skill-chip-effects{display:grid;border:1px solid var(--shd-border)}.skill-chip-effects>div{display:grid;grid-template-columns:27px minmax(140px,1fr) 75px 75px 27px;align-items:center;gap:5px;padding:5px;border-top:1px solid #282e2f}.skill-chip-effects>div:first-child{border-top:0}.skill-chip-effects strong{display:grid;place-items:center;height:100%;background:#171b1c;color:var(--shd-orange);font-size:var(--shd-type-telemetry)}.skill-chip-effects select,.skill-chip-effects input{min-width:0;margin:0;padding:7px;font-size:var(--shd-type-telemetry)}.skill-chip-effects button{height:28px;border:1px solid #563734;background:#1d1212;color:var(--shd-danger);cursor:pointer}.skill-chip-effects button:disabled{opacity:.3;cursor:not-allowed}.skill-chip-row{min-width:1080px;grid-template-columns:1.15fr .45fr 1.1fr 1.1fr .85fr .5fr .3fr}.skill-chip-quality-pair{display:flex;gap:4px}.skill-chip-quality-pair b{padding:3px 4px;border:1px solid currentColor;font-size:var(--shd-type-telemetry)}.skill-chip-row>div:nth-child(4){display:grid;gap:3px}.skill-chip-row>div:nth-child(4) small{color:#626a6c;font-size:var(--shd-type-telemetry)}@media(max-width:1050px){.skill-chip-identity{grid-template-columns:var(--isac-icon-editor-width) 1fr}.quality-scope{grid-column:1/3}.skill-chip-pools,.skill-chip-talent-grid,.skill-chip-preview{grid-template-columns:1fr}}@media(max-width:700px){.skill-chip-identity,.skill-chip-bonus-grid,.skill-chip-range-fields{grid-template-columns:1fr}.quality-scope{grid-column:auto}.skill-chip-preview .segmented-choice>div{height:auto;grid-template-columns:repeat(2,1fr)}.skill-chip-preview .segmented-choice span{min-height:44px}.skill-chip-effects>div{grid-template-columns:27px 1fr}.skill-chip-effects input,.skill-chip-effects button{grid-column:2}.skill-chip-pools{grid-template-columns:1fr}} -.profile-metrics>a{display:flex;flex-direction:column;justify-content:center;padding:0 16px;border-left:1px solid var(--shd-border);color:inherit}.profile-metrics>a:hover,.profile-metrics>a:focus-visible{background:#171b1c}.profile-metrics>a strong{color:var(--shd-orange)}.profile-notifications{border-top:2px solid #4f4329}.profile-notification-list{display:grid;border:1px solid var(--shd-border)}.profile-notification-list>a{min-width:0;display:grid;grid-template-columns:145px minmax(0,1fr) 55px;align-items:center;gap:10px;padding:9px 10px;border-top:1px solid var(--shd-border);background:#0f1213;color:#aeb4b5}.profile-notification-list>a:first-child{border-top:0}.profile-notification-list>a.unread{background:#151718;box-shadow:inset 2px 0 var(--shd-orange)}.profile-notification-list>a.mandatory{box-shadow:inset 2px 0 #68a9c4}.profile-notification-list>a:hover,.profile-notification-list>a:focus-visible{background:#191d1e}.profile-notification-list>a>div,.profile-notification-list>a>span{min-width:0;display:grid;gap:3px}.profile-notification-list>a b{color:var(--shd-orange);font-size:var(--shd-type-telemetry)}.profile-notification-list>a strong{overflow:hidden;font-size:var(--shd-type-telemetry);text-overflow:ellipsis;white-space:nowrap}.profile-notification-list>a small{overflow:hidden;color:#697174;font-size:var(--shd-type-telemetry);text-overflow:ellipsis;white-space:nowrap}.profile-notification-list>a em{justify-self:end;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-style:normal;font-weight:900}.profile-empty.compact{min-height:90px}@media(max-width:700px){.profile-notification-list>a{grid-template-columns:1fr auto}.profile-notification-list>a>div{grid-column:1/3}.profile-notification-list>a>span small{white-space:normal}.profile-metrics>a{min-height:55px;border-top:1px solid var(--shd-border)}} -.profile-metrics>a{min-width:77px;height:59px;align-items:center;padding:7px;border:1px solid var(--shd-border);background:#111415} -.notification-command{align-items:center}.notification-command>div:first-child{display:grid;align-content:center}.notification-command>div:last-child{align-self:center}.notification-preference-head>span:not(:first-child){text-align:center}.notification-preference-row>label{justify-content:center}.notification-list>article{grid-template-columns:190px minmax(0,1fr);transition:border-color .14s ease,background .14s ease}.notification-list>article.isac-message{border-left:2px solid #68a9c4;background:linear-gradient(90deg,#101a1e 0,#101314 34%)}.notification-list>article.positive{border-left-color:var(--shd-success)}.notification-list>article.negative{border-left-color:var(--shd-danger)}.notification-list>article.positive:not(.isac-message){background:linear-gradient(90deg,#101914 0,#101314 34%)}.notification-list>article.negative:not(.isac-message){background:linear-gradient(90deg,#1b1111 0,#101314 34%)}.notification-source{grid-template-columns:38px minmax(0,1fr);align-items:center;align-content:center;gap:9px}.notification-source>img{width:38px;height:38px;object-fit:cover;border:1px solid #3a4548;border-radius:50%;background:#111}.notification-source>span{min-width:0;display:grid;gap:4px}.notification-source>span b,.notification-source>span small{overflow:hidden;text-overflow:ellipsis}.notification-source>span b{white-space:normal}.notification-source>span small{color:#667073;font-size:var(--shd-type-telemetry);white-space:nowrap}.notification-list article header>div{display:flex;align-items:center;gap:8px;min-width:0}.notification-tone{flex:0 0 auto;padding:3px 5px;border:1px solid #50585a;color:#899193;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.6px}.positive .notification-tone{border-color:#41684b;color:var(--shd-success)}.negative .notification-tone{border-color:#6d3f39;color:var(--shd-danger)}.isac-message .notification-source b{color:#8ecce5}.notification-list article footer>button:first-child{color:var(--shd-orange)}.notification-detail-modal{width:min(720px,calc(100vw - 24px));border-top:3px solid #687174}.notification-detail-modal.isac-message{border-top-color:#68a9c4}.notification-detail-modal.positive{border-top-color:var(--shd-success)}.notification-detail-modal.negative{border-top-color:var(--shd-danger)}.notification-detail-sender{display:grid;grid-template-columns:48px 1fr 34px;align-items:center;gap:10px;padding-bottom:10px;border-bottom:1px solid var(--shd-border)}.notification-detail-sender>img{width:48px;height:48px;object-fit:cover;border:1px solid #485052;border-radius:50%;background:#111}.notification-detail-sender>div{display:grid;gap:4px}.notification-detail-sender span{color:#697174;font-size:var(--shd-type-telemetry);letter-spacing:1px}.notification-detail-sender b{font-size:var(--shd-type-telemetry)}.notification-detail-sender>button{height:34px;border:1px solid var(--shd-border);background:#111415;color:#aeb4b5;cursor:pointer}.notification-detail-state{display:grid;grid-template-columns:1fr auto auto;align-items:center;gap:12px;margin:10px 0;padding:7px 9px;border:1px solid var(--shd-border);background:#0d1011}.notification-detail-state span,.notification-detail-state time{color:#6f7779;font-size:var(--shd-type-telemetry);letter-spacing:.7px}.notification-detail-state b{color:#899193;font-size:var(--shd-type-telemetry)}.notification-detail-modal.positive .notification-detail-state b{color:var(--shd-success)}.notification-detail-modal.negative .notification-detail-state b{color:var(--shd-danger)}.notification-detail-modal h2{margin:15px 0 8px;font-size:20px}.notification-detail-modal>p{min-height:90px;margin:0;padding:13px;border-left:2px solid var(--shd-border);background:#0e1112;color:#c0c5c6;font:13px/1.7 var(--shd-font-editorial)}.notification-detail-modal.isac-message>p{border-left-color:#68a9c4}.notification-detail-modal.positive>p{border-left-color:var(--shd-success)}.notification-detail-modal.negative>p{border-left-color:var(--shd-danger)}.notification-detail-modal .form-actions{align-items:center}.notification-detail-modal .form-actions form{margin-right:auto}.notification-detail-modal .form-actions .button{min-height:40px}@media(max-width:700px){.notification-list>article{grid-template-columns:1fr}.notification-source{display:grid;grid-template-columns:38px 1fr;justify-content:initial}.notification-list article header>div{align-items:flex-start;flex-direction:column}.notification-detail-state{grid-template-columns:1fr auto}.notification-detail-state time{grid-column:1/3}.notification-detail-modal .form-actions{display:grid;grid-template-columns:1fr}.notification-detail-modal .form-actions form,.notification-detail-modal .form-actions button,.notification-detail-modal .form-actions a{width:100%;margin:0}} -.notification-command{padding:0 0 18px}.notification-command-actions{display:flex!important;align-items:stretch!important;gap:8px!important}.notification-unread-summary{min-width:86px;display:grid;grid-template-columns:auto 1fr;align-items:center;gap:7px;padding:7px 11px;border:1px solid var(--shd-border);background:#0d1011}.notification-unread-summary strong{grid-row:auto!important;font-size:25px!important;line-height:1}.notification-unread-summary span{align-self:center;line-height:1.2}.notification-command-actions>button,.notification-toolbar button{min-height:40px;display:inline-flex;align-items:center;justify-content:center;margin:0!important;padding:0 18px;line-height:1;white-space:nowrap}.notification-command-actions>button>span{font-size:var(--shd-type-telemetry)}.notification-toolbar{align-items:center;padding:10px 0}.notification-toolbar form{align-items:center;margin:0}.notification-toolbar select{min-width:125px;height:40px}.notification-toolbar form:first-child button{min-width:150px}.notification-toolbar>form:last-child{margin-left:auto}@media(max-width:700px){.notification-command-actions{width:100%;display:grid!important;grid-template-columns:auto 1fr}.notification-command-actions>button{width:100%}.notification-toolbar{align-items:stretch}.notification-toolbar>form:last-child{margin-left:0}} -.profile-row>a{color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:700}.builder-save-form{display:inline-block}.builder-save-form fieldset{display:flex}.builder-persistence{display:grid;grid-template-columns:minmax(300px,520px) 1fr auto;gap:14px;align-items:end;padding:11px 2px;border-top:1px solid var(--shd-border)}.builder-persistence p{margin:0 0 9px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry)}.builder-share-link{align-self:center;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:700;letter-spacing:1px}.catalogue-empty{grid-column:1/-1;display:grid;justify-items:center;gap:12px;padding:60px;border:1px solid var(--shd-border);background:var(--shd-panel);text-align:center}.catalogue-empty>span{color:var(--shd-orange);font-size:34px}.catalogue-empty p{margin:0;color:var(--shd-text-muted)}.build-open{display:block;text-align:center}.shared-build-page{padding-top:28px}.shared-build-command{display:flex;justify-content:space-between;gap:24px;align-items:end;padding:22px;border:1px solid var(--shd-border);border-top:2px solid var(--shd-orange);background:var(--shd-panel)}.shared-build-command h1{margin:8px 0;font-size:clamp(30px,4vw,52px)}.shared-build-command p:last-child{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry)}.shared-build-command>div:last-child{display:flex;gap:8px}.shared-build-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin-top:10px}.shared-build-grid>section{border:1px solid var(--shd-border);background:var(--shd-panel)}.shared-build-grid>section>header{display:flex;justify-content:space-between;padding:10px 12px;border-bottom:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:1.2px}.shared-build-grid article{display:grid;gap:4px;min-height:70px;padding:12px;border-bottom:1px solid var(--shd-border)}.shared-build-grid article:last-child{border-bottom:0}.shared-build-grid article small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:1px}.shared-build-grid article strong{font-size:12px}.shared-build-grid article span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry)}@media(max-width:760px){.builder-command .page-title,.shared-build-command{align-items:stretch;flex-direction:column}.builder-command .actions,.shared-build-command>div:last-child{flex-wrap:wrap}.builder-persistence{grid-template-columns:1fr}.shared-build-grid{grid-template-columns:1fr}} +.loadout-workspace { + display: grid; + grid-template-columns: minmax(230px, 280px) minmax(420px, 1fr) minmax( + 290px, + 350px + ); + gap: 10px; + align-items: start; +} +.loadout-slots, +.loadout-editor, +.loadout-summary { + border: 1px solid var(--shd-border); + background: #111415; +} +.loadout-slots > header, +.summary-section > header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 9px 10px; + border-bottom: 1px solid var(--shd-border); + background: #0d1011; + color: #6d7577; + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; +} +.loadout-slots > header b, +.summary-section > header b { + color: var(--shd-orange); +} +.loadout-slot-group { + display: grid; + gap: 4px; + padding: 5px; + border-bottom: 1px solid #292e2f; +} +.loadout-slot-group.weapons { + grid-template-columns: 1fr 1fr; +} +.loadout-slot-group.gear { + grid-template-columns: 1fr 1fr; +} +.loadout-slot-group.systems { + grid-template-columns: 1fr 1fr; +} +.loadout-slot-group button { + min-width: 0; + min-height: 65px; + display: grid; + align-content: center; + gap: 3px; + padding: 8px; + border: 1px solid #2b3031; + border-left: 2px solid #3b4142; + background: #101314; + color: #c5c9ca; + text-align: left; + cursor: pointer; +} +.loadout-slot-group button:hover, +.loadout-slot-group button:focus-visible { + border-color: #8b682b; + background: #171b1c; +} +.loadout-slot-group button:active { + transform: translateY(1px); + filter: brightness(0.88); +} +.loadout-slot-group button.active { + border-left-color: var(--shd-orange); + background: #1b170f; +} +.loadout-slot-group small { + color: #646c6e; + font-size: var(--shd-type-telemetry); + letter-spacing: 0.8px; +} +.loadout-slot-group strong { + overflow: hidden; + font-size: var(--shd-type-telemetry); + text-overflow: ellipsis; + white-space: nowrap; +} +.loadout-slot-group span { + overflow: hidden; + color: #7c8486; + font-size: var(--shd-type-telemetry); + text-overflow: ellipsis; + white-space: nowrap; +} +.loadout-editor { + min-height: 680px; + padding: 12px; + border-top: 2px solid var(--shd-orange); +} +.loadout-editor-head { + display: flex; + justify-content: space-between; + align-items: end; + padding-bottom: 9px; + border-bottom: 1px solid #303536; +} +.loadout-editor-head span { + color: #687072; + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; +} +.loadout-editor-head h2 { + margin: 3px 0 0; + font-size: 19px; +} +.loadout-editor-head > b { + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); + letter-spacing: 0.8px; +} +.builder-picker { + position: relative; + margin-top: 9px; +} +.builder-picker > span { + display: block; + margin-bottom: 5px; + color: #737b7d; + font-size: var(--shd-type-telemetry); + font-weight: 800; + letter-spacing: 0.8px; +} +.builder-picker-current { + width: 100%; + min-height: 48px; + display: grid; + grid-template-columns: 34px minmax(0, 1fr) auto; + grid-template-rows: 1fr 1fr; + align-items: center; + column-gap: 8px; + padding: 6px 9px; + border: 1px solid #343a3b; + background: #0e1112; + color: #d7d9d5; + text-align: left; +} +.builder-picker-current img, +.builder-picker-current > i, +.builder-picker-panel button img, +.builder-picker-panel button > i { + grid-row: 1/3; + width: 30px; + height: 30px; + object-fit: contain; +} +.builder-picker-current > i, +.builder-picker-panel button > i { + display: grid; + place-items: center; + color: var(--shd-orange); + font-style: normal; +} +.builder-picker-current b { + align-self: end; + font-size: var(--shd-type-telemetry); +} +.builder-picker-current small { + align-self: start; + color: #666e70; + font-size: var(--shd-type-telemetry); +} +.builder-picker-current em { + grid-column: 3; + grid-row: 1/3; + color: var(--shd-orange); + font-size: 14px; + font-style: normal; +} +.builder-picker-panel { + position: absolute; + z-index: 30; + top: 100%; + left: 0; + right: 0; + padding: 6px; + border: 1px solid #5a4a2d; + background: #0b0e0f; + box-shadow: 0 12px 25px #000b; +} +.builder-picker-panel > input { + height: 36px; + margin: 0 0 5px; + padding: 0 9px; + font-size: var(--shd-type-telemetry); +} +.builder-picker-panel > div { + max-height: 250px; + overflow: auto; +} +.builder-picker-panel > div > button { + width: 100%; + min-height: 42px; + display: grid; + grid-template-columns: 30px minmax(0, 1fr) auto; + align-items: center; + gap: 7px; + padding: 5px 7px; + border: 0; + border-top: 1px solid #272c2d; + background: #111415; + color: #c4c8c9; + text-align: left; +} +.builder-picker-panel > div > button:hover, +.builder-picker-panel > div > button.selected { + background: #1b170f; +} +.builder-picker-panel > div > button:disabled { + opacity: 0.35; +} +.builder-picker-panel button span { + display: grid; + gap: 2px; +} +.builder-picker-panel button b { + font-size: var(--shd-type-telemetry); +} +.builder-picker-panel button small, +.builder-picker-panel button em { + color: #687072; + font-size: var(--shd-type-telemetry); + font-style: normal; +} +.builder-picker-panel button.selected em { + color: var(--shd-orange); +} +.builder-picker-panel p { + padding: 12px; + color: #626a6c; + font-size: var(--shd-type-telemetry); +} +.builder-field-grid, +.builder-range-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 7px; + margin-top: 8px; +} +.builder-field-grid > label, +.builder-range-grid > label, +.builder-chip-rolls label, +.builder-rolls label { + color: #737b7d; + font-size: var(--shd-type-telemetry); + font-weight: 800; + letter-spacing: 0.7px; +} +.builder-field-grid select, +.builder-range-grid input, +.builder-chip-rolls input, +.builder-rolls input { + height: 38px; + margin-top: 5px; + padding: 0 8px; + font-size: var(--shd-type-telemetry); +} +.builder-range-grid label > span { + float: right; + color: #5f6769; + font-size: var(--shd-type-telemetry); +} +.builder-range-grid.weapon { + grid-template-columns: repeat(2, 1fr); +} +.builder-fixed { + display: flex; + align-items: end; + gap: 5px; + padding-bottom: 1px; +} +.builder-fixed span { + flex: 1; + padding: 9px 5px; + border: 1px solid #303536; + color: #8e9698; + font-size: var(--shd-type-telemetry); + text-align: center; +} +.builder-section-title { + display: flex; + justify-content: space-between; + align-items: end; + margin: 13px 0 5px; +} +.builder-section-title > div { + display: grid; + gap: 3px; +} +.builder-section-title span { + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.8px; +} +.builder-section-title small { + color: #626a6c; + font-size: var(--shd-type-telemetry); +} +.builder-section-title button { + border: 0; + background: transparent; + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); +} +.builder-section-title > b { + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); +} +.builder-rolls { + display: grid; + gap: 5px; +} +.builder-rolls > div, +.builder-chip-rolls > div { + display: grid; + grid-template-columns: minmax(0, 1fr) 105px 28px; + align-items: end; + gap: 6px; + padding: 6px; + border: 1px solid #2d3233; + background: #0f1213; +} +.builder-rolls .builder-picker, +.builder-chip-rolls .builder-picker { + margin: 0; +} +.builder-rolls > div > button { + height: 38px; + border: 1px solid #563734; + background: #1d1212; + color: #da746c; +} +.builder-chip-rolls { + display: grid; + gap: 6px; + margin-top: 8px; +} +.builder-chip-rolls > div { + grid-template-columns: minmax(0, 1fr) 105px; +} +.builder-augment-list { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 5px; +} +.builder-augment-list label { + min-height: 45px; + display: grid; + grid-template-columns: 18px 1fr auto; + align-items: center; + gap: 7px; + padding: 7px; + border: 1px solid #303536; + background: #0f1213; + cursor: pointer; +} +.builder-augment-list input { + width: auto; + margin: 0; + accent-color: var(--shd-orange); +} +.builder-augment-list span { + display: grid; + gap: 3px; +} +.builder-augment-list b { + font-size: var(--shd-type-telemetry); +} +.builder-augment-list small { + color: #687072; + font-size: var(--shd-type-telemetry); +} +.builder-augment-list em { + color: #a77b31; + font-size: var(--shd-type-telemetry); + font-style: normal; +} +.builder-inherited { + display: grid; + grid-template-columns: repeat(3, 1fr); + margin-top: 8px; + border: 1px solid #303536; +} +.builder-inherited span { + display: grid; + gap: 4px; + padding: 8px; + border-left: 1px solid #303536; +} +.builder-inherited span:first-child { + border-left: 0; +} +.builder-inherited small { + color: #687072; + font-size: var(--shd-type-telemetry); +} +.builder-inherited b { + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); +} +.loadout-summary { + position: sticky; + top: 88px; + border-top: 2px solid var(--shd-orange); +} +.summary-head { + display: flex; + justify-content: space-between; + align-items: end; + padding: 12px; +} +.summary-head h2 { + margin: 4px 0 0; + font-size: 20px; +} +.summary-head > b { + color: var(--shd-orange); + font-size: 22px; +} +.summary-core { + display: grid; + grid-template-columns: repeat(3, 1fr); + border-top: 1px solid #303536; + border-bottom: 1px solid #303536; +} +.summary-core > div { + display: flex; + align-items: center; + gap: 5px; + padding: 9px 6px; + border-left: 1px solid #303536; +} +.summary-core > div:first-child { + border-left: 0; +} +.summary-core .game-icon { + width: 22px; + height: 22px; +} +.summary-core span { + display: grid; + color: #687072; + font-size: var(--shd-type-telemetry); +} +.summary-core b { + color: #d6d9da; + font-size: var(--shd-type-telemetry); +} +.summary-scroll { + max-height: calc(100vh - 340px); + overflow: auto; +} +.summary-section > header { + position: sticky; + top: 0; + z-index: 2; +} +.summary-stat, +.summary-bonus { + display: flex; + justify-content: space-between; + gap: 8px; + padding: 8px 10px; + border-top: 1px solid #252a2b; +} +.summary-stat > span, +.summary-bonus > span { + display: grid; + gap: 3px; + font-size: var(--shd-type-telemetry); +} +.summary-stat small, +.summary-bonus small { + color: #626a6c; + font-size: var(--shd-type-telemetry); +} +.summary-stat > b { + color: #d9dbdc; + font-size: var(--shd-type-telemetry); +} +.summary-bonus em { + color: var(--shd-success); + font-size: var(--shd-type-telemetry); + font-style: normal; + text-align: right; +} +.summary-section > p { + margin: 0; + padding: 12px; + color: #626a6c; + font-size: var(--shd-type-telemetry); + line-height: 1.5; +} +.summary-talent { + display: grid; + grid-template-columns: 18px 1fr; + gap: 7px; + padding: 9px 10px; + border-top: 1px solid #252a2b; + cursor: pointer; +} +.summary-talent input { + width: auto; + margin: 2px 0 0; + accent-color: var(--shd-orange); +} +.summary-talent span { + display: grid; + gap: 3px; +} +.summary-talent b { + font-size: var(--shd-type-telemetry); +} +.summary-talent small { + color: #8a682f; + font-size: var(--shd-type-telemetry); +} +.summary-talent em { + color: #727a7c; + font: var(--shd-type-telemetry)/1.45 var(--shd-font-editorial); +} +.loadout-summary .notice { + margin: 0; + padding: 11px; + border-top: 1px solid #303536; + border-left: 0; +} +.builder-empty-data { + display: flex; + gap: 12px; + margin-bottom: 10px; + padding: 12px; + border: 1px solid #65403a; + border-left: 3px solid var(--shd-danger); + background: #1c1111; +} +.builder-empty-data strong { + font-size: var(--shd-type-telemetry); +} +.builder-empty-data span { + color: #a87870; + font-size: var(--shd-type-telemetry); +} +@media (max-width: 1200px) { + .loadout-workspace { + grid-template-columns: 240px minmax(390px, 1fr); + } + .loadout-summary { + position: static; + grid-column: 1/3; + } + .summary-scroll { + max-height: none; + display: grid; + grid-template-columns: repeat(3, 1fr); + } + .summary-section { + border-left: 1px solid #303536; + } + .summary-section:first-child { + border-left: 0; + } +} +@media (max-width: 800px) { + .loadout-workspace { + grid-template-columns: 1fr; + } + .loadout-slots, + .loadout-editor, + .loadout-summary { + grid-column: auto; + } + .loadout-editor { + min-height: 0; + } + .summary-scroll { + display: block; + } + .summary-section { + border-left: 0; + } + .loadout-summary { + order: 3; + } + .loadout-slot-group.gear { + grid-template-columns: repeat(3, 1fr); + } +} +@media (max-width: 560px) { + .loadout-slot-group.gear, + .loadout-slot-group.systems { + grid-template-columns: 1fr 1fr; + } + .builder-field-grid, + .builder-range-grid, + .builder-range-grid.weapon, + .builder-augment-list, + .builder-inherited { + grid-template-columns: 1fr; + } + .builder-inherited span { + border-left: 0; + border-top: 1px solid #303536; + } + .builder-rolls > div, + .builder-chip-rolls > div { + grid-template-columns: 1fr; + } + .builder-rolls > div > button { + min-height: 44px; + } + .builder-picker-current { + min-height: 52px; + } + .builder-picker-panel > div > button { + min-height: 46px; + } +} +.mobile-row-open { + display: none; +} +@media (max-width: 700px) { + .mobile-detail-row { + position: relative; + min-height: 48px; + padding-bottom: 25px; + cursor: pointer; + } + .dashboard-row > .mobile-row-open { + position: absolute; + z-index: 2; + inset: 0; + width: 100%; + height: 100%; + display: block; + justify-self: stretch; + margin: 0; + padding: 0; + border: 0; + background: transparent; + color: transparent; + font-size: 0; + cursor: pointer; + } + .mobile-detail-row > .row-action, + .mobile-detail-row > button:not(.mobile-row-open) { + display: none; + } + .mobile-detail-row::after { + content: 'TAP TO VIEW ›'; + position: absolute; + right: 10px; + bottom: 7px; + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.8px; + pointer-events: none; + } + .moderation-row.mobile-detail-row::after { + content: 'TAP TO REVIEW ›'; + } + .mobile-detail-row:hover { + border-color: #665026; + background: #171713; + } + .mobile-detail-row:has(.mobile-row-open:focus-visible) { + outline: 2px solid var(--shd-orange); + outline-offset: -2px; + } + .mobile-detail-row:has(.mobile-row-open:active) { + transform: translateY(1px); + filter: brightness(0.86); + } +} +@media (prefers-reduced-motion: reduce) { + .mobile-detail-row:has(.mobile-row-open:active) { + transform: none; + } +} +.avatar-review-filter { + grid-template-columns: minmax(220px, 1fr) auto; +} +.avatar-review-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 7px; +} +.avatar-review-card { + min-width: 0; + display: grid; + grid-template-columns: 112px minmax(0, 1fr); + border: 1px solid var(--shd-border); + background: #111415; +} +.avatar-review-image { + position: relative; + min-height: 142px; + display: grid; + place-items: center; + overflow: hidden; + border-right: 1px solid var(--shd-border); + background: #0b0e0f; +} +.avatar-review-image img { + width: 100%; + height: 100%; + object-fit: cover; +} +.avatar-review-image > span { + padding: 10px; + color: #596164; + font-size: var(--shd-type-telemetry); + font-weight: 900; + line-height: 1.5; + text-align: center; +} +.avatar-review-image > b { + position: absolute; + right: 0; + bottom: 0; + padding: 5px 7px; + border-top: 1px solid currentColor; + border-left: 1px solid currentColor; + background: #171b1ce8; + color: #889092; + font-size: var(--shd-type-telemetry); + letter-spacing: 0.7px; +} +.avatar-review-image > b.pending { + color: var(--shd-orange); +} +.avatar-review-image > b.approved { + color: var(--shd-success); +} +.avatar-review-image > b.declined { + color: var(--shd-danger); +} +.avatar-review-details { + min-width: 0; + display: grid; + align-content: start; + gap: 4px; + padding: 9px; +} +.avatar-review-details > strong { + font-size: var(--shd-type-telemetry); +} +.avatar-review-details > small { + overflow: hidden; + color: #697174; + font-size: var(--shd-type-telemetry); + text-overflow: ellipsis; + white-space: nowrap; +} +.avatar-review-details dl { + display: grid; + gap: 4px; + margin: 5px 0; +} +.avatar-review-details dl > div { + display: flex; + justify-content: space-between; + gap: 8px; + border-top: 1px solid #292e2f; + padding-top: 4px; +} +.avatar-review-details dt, +.avatar-review-details dd { + margin: 0; + font-size: var(--shd-type-telemetry); +} +.avatar-review-details dt { + color: #697174; +} +.avatar-review-details dd { + color: #b9bebe; + text-align: right; +} +.avatar-review-details > p { + margin: 2px 0; + color: #8c9495; + font-size: var(--shd-type-telemetry); + line-height: 1.4; +} +.avatar-review-details > button { + min-height: 31px; + margin-top: auto; +} +.avatar-review-modal { + width: min(680px, calc(100vw - 24px)); +} +.avatar-review-preview { + height: min(46vh, 390px); + display: grid; + place-items: center; + margin: 9px 0; + overflow: hidden; + border: 1px solid var(--shd-border); + background: #090b0c; +} +.avatar-review-preview img { + width: 100%; + height: 100%; + object-fit: contain; +} +.avatar-review-preview span { + color: #697174; + font-size: var(--shd-type-telemetry); + letter-spacing: 0.8px; +} +.avatar-decision-tabs { + grid-template-columns: repeat(2, 1fr); +} +@media (max-width: 1150px) { + .avatar-review-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} +@media (max-width: 700px) { + .avatar-review-filter, + .avatar-review-grid { + grid-template-columns: 1fr; + } + .avatar-review-card { + grid-template-columns: 96px minmax(0, 1fr); + } + .avatar-review-image { + min-height: 132px; + } + .avatar-review-details > button { + min-height: 44px; + } + .avatar-decision-tabs { + grid-template-columns: 1fr; + } +} +.assignment-chips > button span, +.assignment-results > button > span { + display: grid; + gap: 2px; +} +.assignment-chips > button span small, +.assignment-results > button > span small { + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.55px; +} +.assignment-chips > button span .assignment-item-unit, +.assignment-results > button > span .assignment-item-unit { + color: var(--shd-text-muted); + font-weight: 700; + letter-spacing: 0.45px; +} +.notification-link { + position: relative; + width: 34px; + height: 34px; + display: grid !important; + place-items: center; + border: 1px solid var(--shd-border); + color: var(--shd-orange) !important; +} +.notification-link > span { + font-size: var(--shd-type-telemetry); +} +.notification-link > b { + position: absolute; + top: -5px; + right: -5px; + min-width: 17px; + height: 17px; + display: grid; + place-items: center; + padding: 0 4px; + border: 1px solid #d98921; + background: var(--shd-orange); + color: #111; + font-size: var(--shd-type-telemetry); +} +.mobile-navigation > a > em { + min-width: 20px; + height: 20px; + display: grid; + place-items: center; + padding: 0 4px; + background: var(--shd-orange); + color: #111; + font-size: var(--shd-type-telemetry); + font-style: normal; +} +.notification-page { + max-width: 1480px; + margin: auto; + padding: 36px clamp(14px, 4vw, 56px) 80px; +} +.notification-command { + display: flex; + align-items: end; + justify-content: space-between; + padding-bottom: 18px; + border-bottom: 1px solid var(--shd-border); +} +.notification-command h1 { + margin: 5px 0; + font-size: 48px; +} +.notification-command p { + margin: 0; + color: var(--shd-text-muted); +} +.notification-command > div:last-child { + display: grid; + grid-template-columns: auto auto; + align-items: center; + gap: 2px 8px; +} +.notification-command > div:last-child strong { + grid-row: 1/3; + color: var(--shd-orange); + font-size: 32px; +} +.notification-command > div:last-child span { + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; +} +.notification-command > div:last-child button { + grid-column: 1/3; + margin-top: 6px; +} +.notification-settings { + margin: 10px 0; + padding: 10px; + border: 1px solid #625025; + border-top: 2px solid var(--shd-orange); + background: #111415; +} +.notification-settings .module-head { + padding-bottom: 8px; +} +.notification-settings .module-head > span { + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); + letter-spacing: 0.8px; +} +.notification-rule { + margin: 6px 0; + padding: 8px; + border-left: 2px solid var(--shd-orange); + background: #18150f; + color: #8f887a; + font-size: var(--shd-type-telemetry); + line-height: 1.5; +} +.notification-rule b { + color: #d9bc82; +} +.browser-permission { + margin: 4px 0 8px; +} +.notification-preference-form { + display: grid; +} +.notification-preference-head, +.notification-preference-row { + display: grid; + grid-template-columns: minmax(190px, 1fr) 100px 100px 150px; + align-items: center; + gap: 7px; + padding: 7px 9px; + border-top: 1px solid var(--shd-border); +} +.notification-preference-head { + background: #0d1011; + color: #697174; + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; +} +.notification-preference-row > div { + display: grid; + gap: 3px; +} +.notification-preference-row > div b { + font-size: var(--shd-type-telemetry); +} +.notification-preference-row > div small { + color: #697174; + font-size: var(--shd-type-telemetry); +} +.notification-preference-row label { + display: flex; + align-items: center; + gap: 5px; + color: #8a9293; + font-size: var(--shd-type-telemetry); + cursor: pointer; +} +.notification-preference-row input { + width: auto; + margin: 0; + accent-color: var(--shd-orange); +} +.notification-preference-row select { + height: 31px; + margin: 0; + font-size: var(--shd-type-telemetry); +} +.notification-toolbar { + display: flex; + justify-content: space-between; + gap: 8px; + margin-top: 12px; +} +.notification-toolbar form { + display: flex; + gap: 6px; +} +.notification-toolbar select { + height: 36px; + margin: 0; + font-size: var(--shd-type-telemetry); +} +.notification-list { + display: grid; + border: 1px solid var(--shd-border); +} +.notification-list > article { + display: grid; + grid-template-columns: 145px minmax(0, 1fr); + border-top: 1px solid var(--shd-border); + background: #101314; +} +.notification-list > article:first-child { + border-top: 0; +} +.notification-list > article.unread { + background: #141719; + box-shadow: inset 2px 0 var(--shd-orange); +} +.notification-list > article.mandatory { + box-shadow: inset 2px 0 #68a9c4; +} +.notification-source { + display: grid; + align-content: start; + gap: 4px; + padding: 13px; + border-right: 1px solid var(--shd-border); + background: #0d1011; +} +.notification-source b { + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); + line-height: 1.4; +} +.notification-source span { + color: #667073; + font-size: var(--shd-type-telemetry); +} +.notification-list > article > div:last-child { + padding: 12px 14px; +} +.notification-list article header { + display: flex; + justify-content: space-between; + gap: 12px; +} +.notification-list h2 { + font-size: 12px; +} +.notification-list time { + color: #697174; + font-size: var(--shd-type-telemetry); +} +.notification-list article p { + margin: 7px 0; + color: #9aa1a2; + font: var(--shd-type-supporting)/1.55 var(--shd-font-editorial); +} +.notification-list article footer { + display: flex; + align-items: center; + justify-content: space-between; +} +.notification-list article footer a, +.notification-list article footer button, +.notification-list article footer span { + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.7px; +} +.notification-list article footer button { + padding: 0; + border: 0; + background: none; + cursor: pointer; +} +.notification-list article footer button:hover { + color: #fff; +} +.notification-list article footer form { + margin-left: auto; +} +@media (max-width: 700px) { + .notification-page { + padding-top: 22px; + } + .notification-command { + align-items: start; + flex-direction: column; + gap: 12px; + } + .notification-command > div:last-child { + width: 100%; + } + .notification-preference-head { + display: none; + } + .notification-preference-row { + grid-template-columns: 1fr 1fr 1fr; + } + .notification-preference-row > div { + grid-column: 1/4; + } + .notification-preference-row select { + width: 100%; + } + .notification-toolbar { + align-items: stretch; + flex-direction: column; + } + .notification-toolbar form { + display: grid; + grid-template-columns: 1fr 1fr; + } + .notification-toolbar form button { + grid-column: 1/3; + min-height: 44px; + } + .notification-toolbar > form:last-child button { + width: 100%; + } + .notification-list > article { + grid-template-columns: 1fr; + } + .notification-source { + display: flex; + justify-content: space-between; + border-right: 0; + border-bottom: 1px solid var(--shd-border); + } + .notification-list article header { + display: grid; + } + .notification-list article footer { + min-height: 36px; + } +} +.avatar-remove-reason { + grid-template-columns: 1fr; + align-content: center; +} +.avatar-remove-reason > form { + display: grid; + grid-template-columns: minmax(150px, 1fr) auto; + gap: 6px; +} +.avatar-remove-reason > form > input { + height: 30px; + margin: 0; + padding: 0 7px; + font-size: var(--shd-type-telemetry); +} +.avatar-remove-reason > form > div { + height: 30px; + display: grid; + grid-template-columns: 58px 96px; + gap: 5px; +} +.avatar-remove-reason > form button { + height: 30px; + min-height: 0; +} +@media (max-width: 600px) { + .avatar-remove-reason > form { + grid-template-columns: 1fr; + } + .avatar-remove-reason > form > div { + grid-template-columns: 1fr 1fr; + } +} +.page-breadcrumb { + display: flex; + align-items: center; + gap: 10px; + margin: 0; +} +.page-breadcrumb .breadcrumb-line { + flex: 0 0 28px; + width: 28px; + height: 2px; + background: var(--shd-orange); +} +.page-breadcrumb ol { + display: flex; + align-items: center; + gap: 0; + margin: 0; + padding: 0; + list-style: none; +} +.page-breadcrumb li { + display: flex; + align-items: center; +} +.page-breadcrumb li + li:before { + content: '/'; + margin: 0 8px; + color: #4b5254; +} +.page-breadcrumb a { + color: var(--shd-orange); +} +.page-breadcrumb [aria-current='page'] { + color: #9da1a2; +} +@media (max-width: 700px) { + .page-breadcrumb li + li:before { + margin: 0 6px; + } +} +.page-breadcrumb { + align-self: start; + width: max-content; + height: auto; + min-height: 14px; + line-height: 1; +} +.page-breadcrumb .breadcrumb-line { + margin: 0; +} +.page-breadcrumb ol { + height: auto; +} +.page-breadcrumb li { + line-height: 1; +} +.page-breadcrumb li > span { + display: inline; + width: auto; + height: auto; + margin: 0; + background: transparent; + vertical-align: baseline; +} +.notification-message-header > div { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} +@media (max-width: 700px) { + .notification-message-header { + display: grid !important; + } + .notification-message-header > div { + align-items: flex-start; + flex-direction: column; + } +} +.notification-severity { + flex: 0 0 auto; + padding: 3px 5px; + border: 1px solid #8a6731; + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.6px; +} +.notification-severity.error-severity, +.notification-detail-state .error-severity { + border-color: #6d3f39; + color: var(--shd-danger); +} +.system-message-body { + display: grid; + gap: 6px; + margin: 8px 0 7px; + padding: 8px 10px; + border-left: 2px solid #68a9c4; + background: linear-gradient( + 90deg, + rgba(49, 104, 128, 0.12), + rgba(49, 104, 128, 0.025) + ); +} +.system-message-kicker { + display: flex; + align-items: center; + gap: 6px; + color: #72b8d5; +} +.system-message-kicker span { + font-size: var(--shd-type-telemetry); +} +.system-message-kicker b { + font-size: var(--shd-type-telemetry); + letter-spacing: 1.15px; +} +.system-message-body > p, +.notification-list article .system-message-body > p { + margin: 0; + color: #a8b1b3; + font: var(--shd-type-supporting)/1.5 var(--shd-font-editorial); +} +.system-message-detail { + display: grid; + grid-template-columns: 96px minmax(0, 1fr); + gap: 9px; + padding-top: 6px; + border-top: 1px solid rgba(112, 185, 214, 0.18); +} +.system-message-detail > span { + padding-top: 2px; + color: #79bcd6; + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.9px; +} +.system-message-detail > p, +.notification-list article .system-message-detail > p { + margin: 0; + color: #8f999b; + font: var(--shd-type-telemetry)/1.5 var(--shd-font-editorial); +} +.system-message-body.expanded { + margin: 0; + min-height: 110px; + padding: 15px 16px; + gap: 10px; + border-left-color: #68a9c4; + background: linear-gradient( + 90deg, + rgba(49, 104, 128, 0.16), + rgba(14, 17, 18, 0.9) + ); +} +.system-message-body.expanded .system-message-kicker b { + font-size: var(--shd-type-telemetry); +} +.system-message-body.expanded > p { + color: #c0c7c8; + font-size: 13px; + line-height: 1.7; +} +.system-message-body.expanded .system-message-detail { + grid-template-columns: 125px minmax(0, 1fr); + padding-top: 10px; +} +.system-message-body.expanded .system-message-detail > span { + font-size: var(--shd-type-telemetry); +} +.system-message-body.expanded .system-message-detail > p { + color: #adb6b8; + font-size: 12px; + line-height: 1.65; +} +@media (max-width: 700px) { + .system-message-detail, + .system-message-body.expanded .system-message-detail { + grid-template-columns: 1fr; + gap: 4px; + } +} +.system-message-body:not(.expanded) > p, +.system-message-body:not(.expanded) .system-message-detail > p, +.notification-body-preview { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} +.notification-actions { + display: flex; + align-items: center; + gap: 7px; +} +.notification-actions .notification-response-action { + min-height: 28px; + display: inline-flex; + align-items: center; + padding: 0 10px; + border: 1px solid #6b5329; + background: #1b160d; + color: var(--shd-orange); + transition: var(--shd-transition); +} +.notification-actions .notification-response-action:hover { + border-color: var(--shd-orange); + background: #241b0d; + color: #fff; +} +.notification-actions .notification-response-action:active { + transform: translateY(1px); + filter: brightness(0.9); +} +.notification-actions .notification-response-action:focus-visible { + outline: 2px solid var(--shd-orange); + outline-offset: 2px; +} +.profile-anchor { + position: relative; + top: calc(-1 * var(--shd-header-height)); + visibility: hidden; +} +@media (max-width: 700px) { + .notification-actions { + align-items: stretch; + flex-direction: column; + } + .notification-actions .notification-response-action { + min-height: 44px; + justify-content: center; + } +} +.notification-filter-console { + flex: 1; + display: grid !important; + grid-template-columns: minmax(520px, 1fr) minmax(190px, 0.34fr); + align-items: end !important; + gap: 8px !important; +} +.notification-filter-console > input { + display: none; +} +.notification-filter-group { + min-width: 0; + margin: 0; + padding: 0; + border: 0; +} +.notification-filter-group legend { + margin-bottom: 5px; + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 1px; +} +.notification-filter-group > div { + height: 40px; + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + border: 1px solid #363b3c; + background: #0d0f10; +} +.notification-filter-group label { + min-width: 0; + border-left: 1px solid var(--shd-border); + cursor: pointer; +} +.notification-filter-group label:first-child { + border-left: 0; +} +.notification-filter-group input { + position: absolute; + opacity: 0; + pointer-events: none; +} +.notification-filter-group span { + height: 100%; + display: flex; + align-items: center; + justify-content: center; + gap: 5px; + color: #777f81; + transition: var(--shd-transition); +} +.notification-filter-group span i { + width: 5px; + height: 5px; + border: 1px solid currentColor; + background: transparent; +} +.notification-filter-group b { + overflow: hidden; + text-overflow: ellipsis; + color: inherit; + font-size: var(--shd-type-telemetry); + letter-spacing: 0.6px; + white-space: nowrap; +} +.notification-filter-group label:hover span { + background: #171b1c; + color: #c4c8c9; +} +.notification-filter-group label:active span { + transform: translateY(1px); + filter: brightness(0.88); +} +.notification-filter-group input:checked + span { + background: #171b1c; + color: var(--shd-orange); + box-shadow: inset 0 -2px currentColor; +} +.notification-filter-group input:checked + span i { + background: currentColor; +} +.notification-filter-group input:focus-visible + span { + outline: 2px solid var(--shd-orange); + outline-offset: 2px; +} +.state-filter-group > div { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} +@media (max-width: 900px) { + .notification-filter-console { + grid-template-columns: 1fr; + } + .notification-filter-group > div { + height: auto; + grid-template-columns: repeat(3, 1fr); + } + .notification-filter-group span { + min-height: 44px; + } + .state-filter-group > div { + grid-template-columns: repeat(2, 1fr); + } +} +@media (max-width: 560px) { + .notification-filter-group > div { + grid-template-columns: repeat(2, 1fr); + } +} +.segmented-choice { + min-width: 0; + margin: 0; + padding: 0; + border: 0; +} +.segmented-choice legend { + margin-bottom: 5px; + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 1px; +} +.segmented-choice > div { + height: 32px; + display: grid; + grid-template-columns: repeat(var(--segmented-columns), minmax(0, 1fr)); + border: 1px solid #363b3c; + background: #0d0f10; +} +.segmented-choice label { + min-width: 0; + border-left: 1px solid var(--shd-border); + cursor: pointer; +} +.segmented-choice label:first-child { + border-left: 0; +} +.segmented-choice input { + position: absolute; + width: 1px; + height: 1px; + margin: 0; + opacity: 0; + pointer-events: none; +} +.segmented-choice span { + height: 100%; + display: flex; + align-items: center; + justify-content: center; + gap: 5px; + color: #777f81; + transition: var(--shd-transition); +} +.segmented-choice span i { + width: 5px; + height: 5px; + border: 1px solid currentColor; + background: transparent; +} +.segmented-choice .game-icon { + width: 18px; + height: 18px; + opacity: 0.55; +} +.segmented-choice b { + overflow: hidden; + color: inherit; + font-size: var(--shd-type-telemetry); + letter-spacing: 0.6px; + text-overflow: ellipsis; + text-transform: uppercase; + white-space: nowrap; +} +.segmented-choice label:hover span { + background: #171b1c; + color: #c4c8c9; +} +.segmented-choice label:active span { + transform: translateY(1px); + filter: brightness(0.88); +} +.segmented-choice input:checked + span { + background: #171b1c; + color: var(--shd-orange); + box-shadow: inset 0 -2px currentColor; +} +.segmented-choice input:checked + span i { + background: currentColor; +} +.segmented-choice input:checked + span .game-icon { + opacity: 1; +} +.segmented-choice input:focus-visible + span { + outline: 2px solid var(--shd-orange); + outline-offset: 2px; +} +.segmented-choice input:disabled + span { + opacity: 0.45; + cursor: not-allowed; +} +.protocol-classification-stack .segmented-choice legend { + font-size: var(--shd-type-telemetry); +} +.protocol-classification-stack .segmented-choice > div { + margin-top: 5px; +} +.protocol-classification-stack .quality-standard i, +.protocol-classification-stack .quality-specialized i, +.protocol-classification-stack .quality-superior i, +.protocol-classification-stack .quality-highend i { + background: currentColor; +} +.segmented-choice span.icon-only { + gap: 0; +} +.segmented-choice span.icon-only .game-icon { + width: 24px; + height: 24px; +} +.dashboard-editor .form-grid > .segmented-choice { + align-self: end; + margin: 8px 0; +} +.dashboard-editor .form-grid > .segmented-choice legend, +.talent-identity .segmented-choice legend, +.weapon-identity-grid .segmented-choice legend { + font-size: var(--shd-type-telemetry); +} +.talent-identity { + display: grid; + grid-template-columns: minmax(260px, 1fr) minmax(430px, 1.5fr) !important; + align-items: end; + gap: 9px; +} +.talent-identity > .field { + margin: 0; +} +.weapon-identity-grid { + grid-template-columns: + minmax(360px, 1fr) 200px minmax(145px, 170px) + 104px 40px; + align-items: end; +} +.weapon-identity-grid > .field, +.weapon-identity-grid > .segmented-choice { + margin: 0; +} +.weapon-identity-grid > .segmented-choice:first-of-type > div { + grid-template-columns: repeat(6, 32px); + width: max-content; +} +.weapon-identity-grid > .segmented-choice:first-of-type label, +.weapon-identity-grid > .segmented-choice:first-of-type span { + width: 32px; +} +.weapon-identity-grid > .segmented-choice:nth-of-type(3) > div { + grid-template-columns: repeat(3, 32px); + width: max-content; +} +.weapon-identity-grid > .segmented-choice:nth-of-type(3) label, +.weapon-identity-grid > .segmented-choice:nth-of-type(3) span { + width: 32px; +} +.exotic-toggle > span { + justify-content: center; + padding: 0; + cursor: pointer; +} +.weapon-identity-grid .exotic-toggle input { + position: absolute; + width: 1px; + height: 1px; + margin: 0; + opacity: 0; + pointer-events: none; +} +.exotic-toggle .game-icon { + width: 24px; + height: 24px; + opacity: 0.55; +} +.exotic-toggle span:has(input:checked) { + border-color: #875d25; + background: #211a0e; + color: var(--shd-orange); + box-shadow: inset 0 -2px var(--shd-orange); +} +.exotic-toggle span:has(input:checked) .game-icon { + opacity: 1; +} +.exotic-toggle span:has(input:focus-visible) { + outline: 2px solid var(--shd-orange); + outline-offset: 2px; +} +.weapon-category-label { + display: flex !important; + align-items: center; + gap: 4px; +} +.weapon-category-label .game-icon { + width: 20px; + height: 20px; +} +.weapon-category-label .game-icon:first-child:not(:last-child) { + width: 16px; + height: 16px; +} +.history-filter { + align-items: end; + grid-template-columns: minmax(180px, 1fr) minmax(250px, 0.9fr) 80px; +} +.history-filter .segmented-choice > div { + height: 34px; +} +.audit-filter > .assignment-picker.compact { + height: 40px; +} +.moderation-filter { + grid-template-columns: minmax(220px, 1fr) minmax(280px, 0.9fr) auto; +} +@media (max-width: 1000px) { + .weapon-identity-grid { + grid-template-columns: 1fr 1fr; + } + .weapon-identity-grid > .weapon-name { + grid-column: 1/3; + } +} +@media (max-width: 700px) { + .talent-identity, + .weapon-identity-grid { + grid-template-columns: 1fr !important; + } + .weapon-identity-grid > .weapon-name { + grid-column: auto; + } + .talent-identity .segmented-choice > div, + .weapon-identity-grid .segmented-choice > div, + .dashboard-editor .form-grid > .segmented-choice > div { + height: auto; + } + .talent-identity .segmented-choice span, + .weapon-identity-grid .segmented-choice span, + .dashboard-editor .form-grid > .segmented-choice span { + min-height: 44px; + } + .talent-identity .segmented-choice > div { + grid-template-columns: repeat(2, 1fr); + } + .weapon-identity-grid > .segmented-choice:first-of-type > div { + grid-template-columns: repeat(3, 44px); + } + .weapon-identity-grid > .segmented-choice:first-of-type label, + .weapon-identity-grid > .segmented-choice:first-of-type span { + width: 44px; + } + .weapon-identity-grid > .segmented-choice:nth-of-type(3) > div { + grid-template-columns: repeat(3, 44px); + } + .weapon-identity-grid > .segmented-choice:nth-of-type(3) label, + .weapon-identity-grid > .segmented-choice:nth-of-type(3) span { + width: 44px; + } + .exotic-toggle > span { + width: 44px; + min-height: 44px; + } +} +@media (max-width: 650px) { + .history-filter { + grid-template-columns: 1fr; + } + .history-filter .segmented-choice > div { + height: auto; + } + .history-filter .segmented-choice span { + min-height: 44px; + } + .moderation-filter { + grid-template-columns: 1fr; + } +} +.gear-slot-label { + display: flex !important; + align-items: center; + gap: 5px; +} +.gear-slot-label .game-icon { + width: 20px; + height: 20px; +} +.notification-filter-console { + min-width: 0; + grid-template-columns: minmax(0, 3fr) minmax(180px, 1fr); +} +.notification-filter-console .segmented-choice > div { + height: 40px; +} +@media (max-width: 900px) { + .notification-toolbar .notification-filter-console { + grid-template-columns: 1fr; + } + .notification-filter-console .segmented-choice > div { + height: auto; + grid-template-columns: repeat(3, 1fr); + } + .notification-filter-console .segmented-choice span { + min-height: 44px; + } + .notification-filter-console .segmented-choice:last-child > div { + grid-template-columns: repeat(2, 1fr); + } +} +@media (max-width: 560px) { + .notification-filter-console .segmented-choice > div { + grid-template-columns: repeat(2, 1fr); + } +} +.skill-chip-editor { + border-top-color: var(--quality-superior); +} +.skill-chip-identity { + display: grid; + grid-template-columns: + var(--isac-icon-editor-width) minmax(360px, 1fr) + minmax(220px, 0.55fr); + align-items: start; + gap: 10px; +} +.skill-chip-identity > .field { + margin: 0; +} +.quality-scope { + min-height: 62px; + display: grid; + align-content: center; + gap: 5px; + padding: 7px 10px; + border: 1px solid var(--shd-border); + background: #0d1011; +} +.quality-scope > span { + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 1px; +} +.quality-scope > div { + display: flex; + gap: 7px; +} +.quality-scope b { + padding: 4px 6px; + border: 1px solid currentColor; + font-size: var(--shd-type-telemetry); + letter-spacing: 0.7px; +} +.quality-scope small { + color: #626a6c; + font-size: var(--shd-type-telemetry); +} +.skill-chip-bonus, +.skill-chip-talent { + margin-top: 10px; + padding: 9px; + border: 1px solid var(--shd-border); + background: #0d1011; +} +.skill-chip-bonus { + border-left: 2px solid var(--quality-superior); +} +.skill-chip-bonus-grid { + display: grid; + grid-template-columns: minmax(360px, 1fr) minmax(260px, 0.65fr); + gap: 8px; + margin-top: 7px; +} +.skill-chip-range-fields { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 7px; +} +.skill-chip-range-fields .field { + margin: 0; +} +.skill-chip-pools { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 9px; + margin-top: 10px; +} +.skill-chip-pools > div { + min-width: 0; +} +.skill-chip-talent { + border-left: 2px solid var(--quality-high-end); +} +.skill-chip-talent-identity { + display: grid; + grid-template-columns: 0.7fr 1.3fr; + gap: 8px; +} +.skill-chip-talent-identity textarea { + min-height: 66px; +} +.skill-chip-preview { + display: grid; + grid-template-columns: minmax(360px, 0.9fr) minmax(260px, 1.1fr); + align-items: end; + gap: 8px; + margin: 4px 0 10px; +} +.skill-chip-preview > div { + min-height: 42px; + display: grid; + align-content: center; + gap: 5px; + padding: 7px 10px; + border: 1px solid var(--shd-border); + background: #111415; +} +.skill-chip-preview > div span { + color: var(--quality-high-end); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.8px; +} +.skill-chip-preview p { + margin: 0; + color: #c4c9ca; + font: var(--shd-type-telemetry)/1.45 var(--shd-font-editorial); +} +.skill-chip-talent-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 9px; +} +.skill-chip-effects { + display: grid; + border: 1px solid var(--shd-border); +} +.skill-chip-effects > div { + display: grid; + grid-template-columns: 27px minmax(140px, 1fr) 75px 75px 27px; + align-items: center; + gap: 5px; + padding: 5px; + border-top: 1px solid #282e2f; +} +.skill-chip-effects > div:first-child { + border-top: 0; +} +.skill-chip-effects strong { + display: grid; + place-items: center; + height: 100%; + background: #171b1c; + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); +} +.skill-chip-effects select, +.skill-chip-effects input { + min-width: 0; + margin: 0; + padding: 7px; + font-size: var(--shd-type-telemetry); +} +.skill-chip-effects button { + height: 28px; + border: 1px solid #563734; + background: #1d1212; + color: var(--shd-danger); + cursor: pointer; +} +.skill-chip-effects button:disabled { + opacity: 0.3; + cursor: not-allowed; +} +.skill-chip-row { + min-width: 1080px; + grid-template-columns: 1.15fr 0.45fr 1.1fr 1.1fr 0.85fr 0.5fr 0.3fr; +} +.skill-chip-quality-pair { + display: flex; + gap: 4px; +} +.skill-chip-quality-pair b { + padding: 3px 4px; + border: 1px solid currentColor; + font-size: var(--shd-type-telemetry); +} +.skill-chip-row > div:nth-child(4) { + display: grid; + gap: 3px; +} +.skill-chip-row > div:nth-child(4) small { + color: #626a6c; + font-size: var(--shd-type-telemetry); +} +@media (max-width: 1050px) { + .skill-chip-identity { + grid-template-columns: var(--isac-icon-editor-width) 1fr; + } + .quality-scope { + grid-column: 1/3; + } + .skill-chip-pools, + .skill-chip-talent-grid, + .skill-chip-preview { + grid-template-columns: 1fr; + } +} +@media (max-width: 700px) { + .skill-chip-identity, + .skill-chip-bonus-grid, + .skill-chip-range-fields { + grid-template-columns: 1fr; + } + .quality-scope { + grid-column: auto; + } + .skill-chip-preview .segmented-choice > div { + height: auto; + grid-template-columns: repeat(2, 1fr); + } + .skill-chip-preview .segmented-choice span { + min-height: 44px; + } + .skill-chip-effects > div { + grid-template-columns: 27px 1fr; + } + .skill-chip-effects input, + .skill-chip-effects button { + grid-column: 2; + } + .skill-chip-pools { + grid-template-columns: 1fr; + } +} +.profile-metrics > a { + display: flex; + flex-direction: column; + justify-content: center; + padding: 0 16px; + border-left: 1px solid var(--shd-border); + color: inherit; +} +.profile-metrics > a:hover, +.profile-metrics > a:focus-visible { + background: #171b1c; +} +.profile-metrics > a strong { + color: var(--shd-orange); +} +.profile-notifications { + border-top: 2px solid #4f4329; +} +.profile-notification-list { + display: grid; + border: 1px solid var(--shd-border); +} +.profile-notification-list > a { + min-width: 0; + display: grid; + grid-template-columns: 145px minmax(0, 1fr) 55px; + align-items: center; + gap: 10px; + padding: 9px 10px; + border-top: 1px solid var(--shd-border); + background: #0f1213; + color: #aeb4b5; +} +.profile-notification-list > a:first-child { + border-top: 0; +} +.profile-notification-list > a.unread { + background: #151718; + box-shadow: inset 2px 0 var(--shd-orange); +} +.profile-notification-list > a.mandatory { + box-shadow: inset 2px 0 #68a9c4; +} +.profile-notification-list > a:hover, +.profile-notification-list > a:focus-visible { + background: #191d1e; +} +.profile-notification-list > a > div, +.profile-notification-list > a > span { + min-width: 0; + display: grid; + gap: 3px; +} +.profile-notification-list > a b { + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); +} +.profile-notification-list > a strong { + overflow: hidden; + font-size: var(--shd-type-telemetry); + text-overflow: ellipsis; + white-space: nowrap; +} +.profile-notification-list > a small { + overflow: hidden; + color: #697174; + font-size: var(--shd-type-telemetry); + text-overflow: ellipsis; + white-space: nowrap; +} +.profile-notification-list > a em { + justify-self: end; + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); + font-style: normal; + font-weight: 900; +} +.profile-empty.compact { + min-height: 90px; +} +@media (max-width: 700px) { + .profile-notification-list > a { + grid-template-columns: 1fr auto; + } + .profile-notification-list > a > div { + grid-column: 1/3; + } + .profile-notification-list > a > span small { + white-space: normal; + } + .profile-metrics > a { + min-height: 55px; + border-top: 1px solid var(--shd-border); + } +} +.profile-metrics > a { + min-width: 77px; + height: 59px; + align-items: center; + padding: 7px; + border: 1px solid var(--shd-border); + background: #111415; +} +.notification-command { + align-items: center; +} +.notification-command > div:first-child { + display: grid; + align-content: center; +} +.notification-command > div:last-child { + align-self: center; +} +.notification-preference-head > span:not(:first-child) { + text-align: center; +} +.notification-preference-row > label { + justify-content: center; +} +.notification-list > article { + grid-template-columns: 190px minmax(0, 1fr); + transition: + border-color 0.14s ease, + background 0.14s ease; +} +.notification-list > article.isac-message { + border-left: 2px solid #68a9c4; + background: linear-gradient(90deg, #101a1e 0, #101314 34%); +} +.notification-list > article.positive { + border-left-color: var(--shd-success); +} +.notification-list > article.negative { + border-left-color: var(--shd-danger); +} +.notification-list > article.positive:not(.isac-message) { + background: linear-gradient(90deg, #101914 0, #101314 34%); +} +.notification-list > article.negative:not(.isac-message) { + background: linear-gradient(90deg, #1b1111 0, #101314 34%); +} +.notification-source { + grid-template-columns: 38px minmax(0, 1fr); + align-items: center; + align-content: center; + gap: 9px; +} +.notification-source > img { + width: 38px; + height: 38px; + object-fit: cover; + border: 1px solid #3a4548; + border-radius: 50%; + background: #111; +} +.notification-source > span { + min-width: 0; + display: grid; + gap: 4px; +} +.notification-source > span b, +.notification-source > span small { + overflow: hidden; + text-overflow: ellipsis; +} +.notification-source > span b { + white-space: normal; +} +.notification-source > span small { + color: #667073; + font-size: var(--shd-type-telemetry); + white-space: nowrap; +} +.notification-list article header > div { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} +.notification-tone { + flex: 0 0 auto; + padding: 3px 5px; + border: 1px solid #50585a; + color: #899193; + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.6px; +} +.positive .notification-tone { + border-color: #41684b; + color: var(--shd-success); +} +.negative .notification-tone { + border-color: #6d3f39; + color: var(--shd-danger); +} +.isac-message .notification-source b { + color: #8ecce5; +} +.notification-list article footer > button:first-child { + color: var(--shd-orange); +} +.notification-detail-modal { + width: min(720px, calc(100vw - 24px)); + border-top: 3px solid #687174; +} +.notification-detail-modal.isac-message { + border-top-color: #68a9c4; +} +.notification-detail-modal.positive { + border-top-color: var(--shd-success); +} +.notification-detail-modal.negative { + border-top-color: var(--shd-danger); +} +.notification-detail-sender { + display: grid; + grid-template-columns: 48px 1fr 34px; + align-items: center; + gap: 10px; + padding-bottom: 10px; + border-bottom: 1px solid var(--shd-border); +} +.notification-detail-sender > img { + width: 48px; + height: 48px; + object-fit: cover; + border: 1px solid #485052; + border-radius: 50%; + background: #111; +} +.notification-detail-sender > div { + display: grid; + gap: 4px; +} +.notification-detail-sender span { + color: #697174; + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; +} +.notification-detail-sender b { + font-size: var(--shd-type-telemetry); +} +.notification-detail-sender > button { + height: 34px; + border: 1px solid var(--shd-border); + background: #111415; + color: #aeb4b5; + cursor: pointer; +} +.notification-detail-state { + display: grid; + grid-template-columns: 1fr auto auto; + align-items: center; + gap: 12px; + margin: 10px 0; + padding: 7px 9px; + border: 1px solid var(--shd-border); + background: #0d1011; +} +.notification-detail-state span, +.notification-detail-state time { + color: #6f7779; + font-size: var(--shd-type-telemetry); + letter-spacing: 0.7px; +} +.notification-detail-state b { + color: #899193; + font-size: var(--shd-type-telemetry); +} +.notification-detail-modal.positive .notification-detail-state b { + color: var(--shd-success); +} +.notification-detail-modal.negative .notification-detail-state b { + color: var(--shd-danger); +} +.notification-detail-modal h2 { + margin: 15px 0 8px; + font-size: 20px; +} +.notification-detail-modal > p { + min-height: 90px; + margin: 0; + padding: 13px; + border-left: 2px solid var(--shd-border); + background: #0e1112; + color: #c0c5c6; + font: 13px/1.7 var(--shd-font-editorial); +} +.notification-detail-modal.isac-message > p { + border-left-color: #68a9c4; +} +.notification-detail-modal.positive > p { + border-left-color: var(--shd-success); +} +.notification-detail-modal.negative > p { + border-left-color: var(--shd-danger); +} +.notification-detail-modal .form-actions { + align-items: center; +} +.notification-detail-modal .form-actions form { + margin-right: auto; +} +.notification-detail-modal .form-actions .button { + min-height: 40px; +} +@media (max-width: 700px) { + .notification-list > article { + grid-template-columns: 1fr; + } + .notification-source { + display: grid; + grid-template-columns: 38px 1fr; + justify-content: initial; + } + .notification-list article header > div { + align-items: flex-start; + flex-direction: column; + } + .notification-detail-state { + grid-template-columns: 1fr auto; + } + .notification-detail-state time { + grid-column: 1/3; + } + .notification-detail-modal .form-actions { + display: grid; + grid-template-columns: 1fr; + } + .notification-detail-modal .form-actions form, + .notification-detail-modal .form-actions button, + .notification-detail-modal .form-actions a { + width: 100%; + margin: 0; + } +} +.notification-command { + padding: 0 0 18px; +} +.notification-command-actions { + display: flex !important; + align-items: stretch !important; + gap: 8px !important; +} +.notification-unread-summary { + min-width: 86px; + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + gap: 7px; + padding: 7px 11px; + border: 1px solid var(--shd-border); + background: #0d1011; +} +.notification-unread-summary strong { + grid-row: auto !important; + font-size: 25px !important; + line-height: 1; +} +.notification-unread-summary span { + align-self: center; + line-height: 1.2; +} +.notification-command-actions > button, +.notification-toolbar button { + min-height: 40px; + display: inline-flex; + align-items: center; + justify-content: center; + margin: 0 !important; + padding: 0 18px; + line-height: 1; + white-space: nowrap; +} +.notification-command-actions > button > span { + font-size: var(--shd-type-telemetry); +} +.notification-toolbar { + align-items: center; + padding: 10px 0; +} +.notification-toolbar form { + align-items: center; + margin: 0; +} +.notification-toolbar select { + min-width: 125px; + height: 40px; +} +.notification-toolbar form:first-child button { + min-width: 150px; +} +.notification-toolbar > form:last-child { + margin-left: auto; +} +@media (max-width: 700px) { + .notification-command-actions { + width: 100%; + display: grid !important; + grid-template-columns: auto 1fr; + } + .notification-command-actions > button { + width: 100%; + } + .notification-toolbar { + align-items: stretch; + } + .notification-toolbar > form:last-child { + margin-left: 0; + } +} +.profile-row > a { + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); + font-weight: 700; +} +.builder-save-form { + display: inline-block; +} +.builder-save-form fieldset { + display: flex; +} +.builder-persistence { + display: grid; + grid-template-columns: minmax(300px, 520px) 1fr auto; + gap: 14px; + align-items: end; + padding: 11px 2px; + border-top: 1px solid var(--shd-border); +} +.builder-persistence p { + margin: 0 0 9px; + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); +} +.builder-share-link { + align-self: center; + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); + font-weight: 700; + letter-spacing: 1px; +} +.catalogue-empty { + grid-column: 1/-1; + display: grid; + justify-items: center; + gap: 12px; + padding: 60px; + border: 1px solid var(--shd-border); + background: var(--shd-panel); + text-align: center; +} +.catalogue-empty > span { + color: var(--shd-orange); + font-size: 34px; +} +.catalogue-empty p { + margin: 0; + color: var(--shd-text-muted); +} +.build-open { + display: block; + text-align: center; +} +.shared-build-page { + padding-top: 28px; +} +.shared-build-command { + display: flex; + justify-content: space-between; + gap: 24px; + align-items: end; + padding: 22px; + border: 1px solid var(--shd-border); + border-top: 2px solid var(--shd-orange); + background: var(--shd-panel); +} +.shared-build-command h1 { + margin: 8px 0; + font-size: clamp(30px, 4vw, 52px); +} +.shared-build-command p:last-child { + margin: 0; + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); +} +.shared-build-command > div:last-child { + display: flex; + gap: 8px; +} +.shared-build-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + margin-top: 10px; +} +.shared-build-grid > section { + border: 1px solid var(--shd-border); + background: var(--shd-panel); +} +.shared-build-grid > section > header { + display: flex; + justify-content: space-between; + padding: 10px 12px; + border-bottom: 1px solid var(--shd-border); + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + letter-spacing: 1.2px; +} +.shared-build-grid article { + display: grid; + gap: 4px; + min-height: 70px; + padding: 12px; + border-bottom: 1px solid var(--shd-border); +} +.shared-build-grid article:last-child { + border-bottom: 0; +} +.shared-build-grid article small { + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; +} +.shared-build-grid article strong { + font-size: 12px; +} +.shared-build-grid article span { + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); +} +@media (max-width: 760px) { + .builder-command .page-title, + .shared-build-command { + align-items: stretch; + flex-direction: column; + } + .builder-command .actions, + .shared-build-command > div:last-child { + flex-wrap: wrap; + } + .builder-persistence { + grid-template-columns: 1fr; + } + .shared-build-grid { + grid-template-columns: 1fr; + } +} /* Command Grid production builder: matrix navigation, contextual editor, and live telemetry rail. */ -.builder-command{min-width:0;border:1px solid var(--shd-border);background:var(--shd-surface)}.builder-command .page-title{min-width:0;padding:14px 16px;margin:0}.builder-command .page-title>div{min-width:0}.builder-command .page-title>div:first-child{flex:1 1 460px;min-width:min(460px,42vw)}.builder-command .page-title input{width:100%;max-width:none;font-size:clamp(27px,3.1vw,46px);letter-spacing:-.03em;text-transform:uppercase}.builder-command .actions{min-width:0;flex:0 0 auto;display:grid;grid-template-columns:repeat(3,minmax(0,auto));align-items:center;gap:7px}.builder-command .actions>span{grid-column:1/-1;color:var(--shd-success);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.08em;text-align:right}.builder-command .builder-persistence{padding:10px 14px}.builder-status{min-width:0;flex-wrap:wrap;gap:5px 20px;padding:9px 14px;border-top:1px solid var(--shd-border);background:#0c0f10}.builder-status>span{min-width:0;overflow-wrap:anywhere}.builder-notes{min-width:0;display:grid;grid-template-columns:minmax(260px,.65fr) minmax(420px,1.35fr);gap:8px;padding:10px 14px;border-top:1px solid var(--shd-border);background:var(--shd-builder-canvas)}.builder-notes>*{min-width:0}.builder-notes>label{display:grid;align-content:start;gap:6px}.builder-notes>label>span{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.08em}.builder-notes>label small{color:var(--shd-text-muted)}.builder-notes textarea{width:100%;min-height:150px;padding:11px;border:1px solid var(--shd-border);border-radius:0;background:#0d0f10;color:var(--shd-text);font:var(--shd-type-body)/1.55 var(--shd-font-editorial);resize:vertical}.loadout-workspace{min-width:0;grid-template-columns:minmax(300px,350px) minmax(480px,1fr) minmax(290px,340px);gap:6px}.loadout-workspace>*,.loadout-editor>*,.loadout-summary>*{min-width:0}.loadout-editor{min-width:0;border-top-color:var(--shd-orange);background:var(--shd-surface)}.loadout-editor-head{min-width:0;gap:10px;margin:-12px -12px 10px;padding:11px 12px;border-bottom-color:var(--shd-border);background:#0c0f10}.loadout-editor-head>div{min-width:0}.loadout-editor-head h2,.loadout-editor-head>b{overflow-wrap:anywhere}.loadout-editor-head>b{flex:0 1 auto;text-align:right}.loadout-summary{min-width:0;background:var(--shd-surface)}.builder-picker-current>*{min-width:0}.builder-picker-current span,.builder-picker-panel button span{min-width:0}.builder-section-title{min-width:0;gap:10px}.builder-section-title>div{min-width:0}.builder-section-title small{overflow-wrap:anywhere}.builder-section-title>b{flex:0 0 auto;text-align:right}.builder-ability-chip-panels.single{grid-template-columns:1fr}.builder-ability-chip-panels.single section>div{grid-template-columns:repeat(3,minmax(0,1fr))}.builder-specialization-identity.ability-identity{grid-template-columns:54px minmax(0,1fr) auto}.builder-specialization-identity.ability-identity>.item-icon{width:50px;height:50px}.shared-build-guide{margin-top:10px;border:1px solid var(--shd-border);border-top:2px solid var(--shd-orange);background:var(--shd-panel)}.shared-build-guide>header{display:flex;justify-content:space-between;padding:9px 12px;border-bottom:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.09em}.shared-build-guide>header b{color:var(--shd-orange)}.shared-build-summary{max-width:90ch;margin:0;padding:15px 18px;border-bottom:1px solid var(--shd-border);color:var(--shd-text);font:16px/1.6 var(--shd-font-editorial)}.shared-build-guide>div{padding:18px}.shared-build-guide h2{margin:0 0 10px;font-size:18px;letter-spacing:.04em} -@media(max-width:1320px){.loadout-workspace{grid-template-columns:minmax(270px,320px) minmax(440px,1fr)}.loadout-summary{position:static;grid-column:1/3}.summary-scroll{max-height:none;display:grid;grid-template-columns:repeat(3,1fr)}} -@media(max-width:1050px){.builder-command .page-title{align-items:stretch;flex-direction:column}.builder-command .page-title>div:first-child{min-width:0}.builder-command .actions{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:7px}.builder-command .actions>span{grid-column:1/-1}.builder-command .actions>.secondary,.builder-command .actions>.primary,.builder-command .actions>a,.builder-command .builder-save-form,.builder-command .builder-save-form fieldset,.builder-command .builder-save-form button{width:100%}} -@media(max-width:800px){.builder-command .page-title>div:first-child{min-width:0}.builder-notes{grid-template-columns:1fr}.loadout-workspace{grid-template-columns:1fr}.loadout-workspace>.loadout-matrix,.loadout-editor,.loadout-summary{grid-column:auto}.builder-ability-chip-panels.single section>div{grid-template-columns:1fr}.builder-specialization-identity.ability-identity{grid-template-columns:48px minmax(0,1fr)}.builder-specialization-identity.ability-identity>b{grid-column:1/-1}.summary-scroll{display:block}} -@media(max-width:560px){.builder-command .actions{width:100%;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px}.builder-command .actions>span,.builder-command .actions>.primary,.builder-command .actions>a.primary,.builder-command .builder-save-form{grid-column:1/-1}.builder-command .actions>.secondary,.builder-command .actions>.primary,.builder-command .actions>a,.builder-command .builder-save-form,.builder-command .builder-save-form fieldset,.builder-command .builder-save-form button{width:100%}.builder-status{display:grid;grid-template-columns:1fr 1fr}.builder-section-title{align-items:flex-start;flex-direction:column}.builder-section-title>b{align-self:flex-start;text-align:left}.builder-rolls.required>div{grid-template-columns:1fr}.loadout-editor-head{align-items:flex-start;flex-wrap:wrap}.loadout-editor-head>b{margin-left:auto}.summary-head{gap:10px}.summary-head>b{flex:0 0 auto}} -.weapon-mod-slot-config{margin-top:7px;padding-top:6px;border-top:1px solid var(--shd-border)}.weapon-mod-slot-config .assignment-picker{margin-top:5px}.weapon-mod-editor{border-top-color:var(--shd-orange)}.weapon-mod-identity{display:grid;grid-template-columns:minmax(300px,1.4fr) 52px minmax(280px,1fr);align-items:end;gap:8px}.weapon-mod-identity>.field,.weapon-mod-identity>.segmented-choice{margin:0}.weapon-mod-identity>.segmented-choice{grid-column:1/-1}.weapon-mod-identity>.segmented-choice>div{height:38px}.weapon-mod-assignment-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:10px}.weapon-mod-secondary{display:grid;grid-template-columns:1fr 1fr;gap:7px}.weapon-mod-quality-section,.weapon-mod-effect-section{margin-top:10px;padding:9px;border:1px solid var(--shd-border);background:#0d1011}.weapon-mod-quality-section{border-left:2px solid var(--quality-high-end)}.weapon-mod-quality-table{display:grid;margin-top:7px;border:1px solid var(--shd-border)}.weapon-mod-quality-row{display:grid;grid-template-columns:110px repeat(4,minmax(180px,1fr));align-items:center;border-top:1px solid var(--shd-border)}.weapon-mod-quality-row:first-child{border-top:0}.weapon-mod-quality-row.heading{min-height:29px;background:#111415;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.weapon-mod-quality-row>span,.weapon-mod-quality-row>strong{padding:8px}.weapon-mod-quality-row>strong{font-size:var(--shd-type-telemetry);letter-spacing:.7px}.weapon-mod-quality-row>div{display:grid;grid-template-columns:1fr 14px 1fr;align-items:center;gap:4px;padding:5px;border-left:1px solid var(--shd-border)}.weapon-mod-quality-row input{height:30px;margin:0;padding:0 6px;font-size:var(--shd-type-telemetry)}.weapon-mod-quality-row i{color:#596164;font-size:var(--shd-type-telemetry);font-style:normal;text-align:center}.weapon-mod-preview{display:grid;grid-template-columns:minmax(360px,.8fr) minmax(420px,1.2fr);align-items:end;gap:8px;margin-top:8px}.weapon-mod-preview>div:first-child{display:grid;grid-template-columns:1fr 1.2fr;align-items:end;gap:8px}.weapon-mod-preview>div:first-child>label{display:grid;gap:5px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.weapon-mod-preview input[type=range]{height:32px;margin:0;padding:0;accent-color:var(--shd-orange)}.weapon-mod-preview-values{display:grid;grid-template-columns:1.2fr 1fr 1fr;border:1px solid var(--shd-border)}.weapon-mod-preview-values>span{min-height:48px;display:grid;grid-template-columns:auto 1fr;grid-template-rows:1fr 1fr;align-items:center;gap:2px 7px;padding:7px;border-left:1px solid var(--shd-border)}.weapon-mod-preview-values>span:first-child{border-left:0}.weapon-mod-preview-values .game-icon{grid-row:1/3;width:28px;height:28px}.weapon-mod-preview-values small{align-self:end;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry)}.weapon-mod-preview-values b{align-self:start;color:var(--shd-orange);font-size:var(--shd-type-telemetry)}.weapon-mod-effects{display:grid;gap:6px;margin-top:7px}.weapon-mod-effects>div{display:grid;grid-template-columns:30px minmax(230px,1fr) repeat(4,minmax(90px,.45fr)) 30px;align-items:end;gap:6px;padding:6px;border:1px solid var(--shd-border);background:#101314}.weapon-mod-effects>div>strong{height:100%;display:grid;place-items:center;background:#171b1c;color:var(--shd-orange);font-size:var(--shd-type-telemetry)}.weapon-mod-effects label{display:grid;gap:4px}.weapon-mod-effects label span{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.5px}.weapon-mod-effects input{height:32px;margin:0;padding:0 6px}.weapon-mod-effects>div>button{height:32px;border:1px solid #563734;background:#1d1212;color:var(--shd-danger);cursor:pointer}.weapon-mod-effects>div>button:disabled{opacity:.3;cursor:not-allowed}.weapon-mod-rule{padding:10px;border:1px solid #74552a;border-left:2px solid var(--quality-high-end);background:#18150d;color:#a89568;font-size:var(--shd-type-telemetry)}.weapon-mod-rule b{color:var(--quality-high-end)}.weapon-mod-row{min-width:1240px;grid-template-columns:1.15fr .55fr .65fr 1fr 1fr .55fr .5fr .3fr}.weapon-mod-primary{display:flex;align-items:center;gap:5px}.weapon-mod-primary .game-icon{width:22px;height:22px}.weapon-mod-row>span small{color:var(--shd-text-muted)}@media(max-width:1100px){.weapon-mod-identity{grid-template-columns:1fr 52px 1fr}.weapon-mod-assignment-grid,.weapon-mod-preview{grid-template-columns:1fr}.weapon-mod-quality-table{overflow-x:auto}.weapon-mod-quality-row{min-width:900px}.weapon-mod-effects>div{grid-template-columns:30px 1fr 1fr 1fr}.weapon-mod-effects label:nth-of-type(3),.weapon-mod-effects label:nth-of-type(4),.weapon-mod-effects>div>button{grid-column:auto}}@media(max-width:700px){.weapon-mod-identity,.weapon-mod-assignment-grid,.weapon-mod-secondary,.weapon-mod-preview>div:first-child{grid-template-columns:1fr}.weapon-mod-identity>.segmented-choice{grid-column:auto}.weapon-mod-identity>.segmented-choice>div{height:auto;grid-template-columns:repeat(2,1fr)}.weapon-mod-identity>.segmented-choice span{min-height:44px}.weapon-mod-effects>div{grid-template-columns:30px 1fr}.weapon-mod-effects label,.weapon-mod-effects>div>button{grid-column:2}.weapon-mod-preview-values{grid-template-columns:1fr}.weapon-mod-preview-values>span{border-left:0;border-top:1px solid var(--shd-border)}.weapon-mod-preview-values>span:first-child{border-top:0}} -.weapon-mod-primary-types{min-height:58px;display:grid;align-content:center;gap:5px;padding:7px;border:1px solid var(--shd-border);background:#0d1011}.weapon-mod-primary-types>span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.weapon-mod-primary-types>div{display:flex;gap:8px}.weapon-mod-primary-types b{display:flex;align-items:center;gap:3px;color:#aab0b1;font-size:var(--shd-type-telemetry)}.weapon-mod-primary-types .game-icon{width:18px;height:18px}.weapon-mod-primary-types small{color:#596164;font-size:var(--shd-type-telemetry)}.weapon-mod-compatibility{margin-top:10px}.weapon-mod-quality-row{grid-template-columns:110px repeat(2,minmax(220px,1fr))}.weapon-mod-quality-row.heading{grid-template-columns:110px repeat(2,minmax(220px,1fr))}.weapon-mod-preview-values{grid-template-columns:1.3fr .7fr}.weapon-mod-preview-values .core-icon-stack{grid-row:1/3}.weapon-mod-preview-values .core-icon-stack .game-icon{grid-row:auto;width:22px;height:22px}.weapon-mod-secondary-section{margin-top:10px;padding:9px;border:1px solid var(--shd-border);border-left:2px solid var(--quality-specialized);background:#0d1011}.weapon-mod-secondary-options{display:grid;gap:6px;margin-top:7px}.weapon-mod-secondary-options>div{display:grid;grid-template-columns:30px minmax(230px,1fr) repeat(4,minmax(150px,.65fr)) 30px;align-items:end;gap:6px;padding:6px;border:1px solid var(--shd-border);background:#101314}.weapon-mod-secondary-options>div>strong{height:100%;display:grid;place-items:center;background:#171b1c;color:var(--shd-orange);font-size:var(--shd-type-telemetry)}.weapon-mod-secondary-options label{display:grid;gap:4px}.weapon-mod-secondary-options label>span{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.5px}.weapon-mod-secondary-options label>div{display:grid;grid-template-columns:1fr 12px 1fr;align-items:center}.weapon-mod-secondary-options input{height:32px;margin:0;padding:0 5px}.weapon-mod-secondary-options i{color:#596164;font-size:var(--shd-type-telemetry);font-style:normal;text-align:center}.weapon-mod-secondary-options>div>button{height:32px;border:1px solid #563734;background:#1d1212;color:var(--shd-danger);cursor:pointer}.weapon-mod-secondary-options>div>button:disabled{opacity:.3;cursor:not-allowed}@media(max-width:1100px){.weapon-mod-quality-row,.weapon-mod-quality-row.heading{min-width:580px}.weapon-mod-secondary-options>div{grid-template-columns:30px 1fr 1fr 1fr}.weapon-mod-secondary-options label:nth-of-type(3),.weapon-mod-secondary-options label:nth-of-type(4){grid-column:auto}}@media(max-width:700px){.weapon-mod-secondary-options>div{grid-template-columns:30px 1fr}.weapon-mod-secondary-options label,.weapon-mod-secondary-options>div>button{grid-column:2}} -.weapon-mod-exotic-quality{min-height:58px;display:flex;align-items:center;gap:8px;padding:7px;border:1px solid #74552a;border-left:2px solid var(--quality-high-end);background:#18150d}.weapon-mod-exotic-quality .game-icon{width:34px;height:34px}.weapon-mod-exotic-quality span{display:grid;gap:3px}.weapon-mod-exotic-quality b{color:var(--quality-high-end);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.weapon-mod-exotic-quality small{color:#a89568;font-size:var(--shd-type-telemetry);letter-spacing:.35px} -.weapon-mod-exotic-toggle>span{width:44px;min-height:42px;padding:0}.weapon-mod-exotic-toggle input{position:absolute;width:1px;height:1px;margin:0;opacity:0;pointer-events:none}.weapon-mod-exotic-toggle .game-icon{width:30px;height:30px}.weapon-mod-compatibility .segmented-choice>div{height:48px}.weapon-mod-compatibility .segmented-choice .game-icon{width:34px;height:34px}.weapon-mod-exotic-section{margin-top:10px;padding:9px;border:1px solid #74552a;border-left:2px solid var(--quality-high-end);background:#0d1011}.weapon-mod-exotic-attributes{display:grid;gap:6px;margin-top:7px}.weapon-mod-exotic-attributes>div{display:grid;grid-template-columns:30px minmax(240px,1fr) 150px 150px 30px;align-items:end;gap:6px;padding:6px;border:1px solid var(--shd-border);background:#101314}.weapon-mod-exotic-attributes>div>strong{height:100%;display:grid;place-items:center;background:#171b1c;color:var(--quality-high-end);font-size:var(--shd-type-telemetry)}.weapon-mod-exotic-attributes label{display:grid;gap:4px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.5px}.weapon-mod-exotic-attributes input{height:32px;margin:0;padding:0 6px}.weapon-mod-exotic-attributes>div>button{height:32px;border:1px solid #563734;background:#1d1212;color:var(--shd-danger);cursor:pointer}.weapon-mod-exotic-attributes>div>button:disabled{opacity:.3;cursor:not-allowed}@media(max-width:700px){.weapon-mod-exotic-attributes>div{grid-template-columns:30px 1fr}.weapon-mod-exotic-attributes label,.weapon-mod-exotic-attributes>div>button{grid-column:2}.weapon-mod-compatibility .segmented-choice>div{height:auto;grid-template-columns:repeat(3,1fr)}.weapon-mod-compatibility .segmented-choice span{min-height:50px}} -.gear-mod-identity{display:grid;grid-template-columns:minmax(320px,1fr) minmax(560px,1.5fr);align-items:end;gap:8px}.gear-mod-identity>.field{margin:0}.gear-mod-primary-types>div{flex-wrap:wrap}.gear-mod-row{min-width:1120px;grid-template-columns:1.2fr 1fr .9fr 1fr .55fr .55fr .3fr}.gear-mod-row>span{display:flex;align-items:center;gap:6px}.gear-mod-row>span small{color:var(--shd-text-muted)}@media(max-width:900px){.gear-mod-identity{grid-template-columns:1fr}}@media(max-width:700px){.gear-mod-primary-types>div{display:grid;grid-template-columns:1fr 1fr}} -.missing-item-icon{opacity:.7;filter:saturate(.75)}.icon-upload-preview .missing-item-icon{width:66px;height:66px;opacity:.62}.registry-icon-identity>.missing-item-icon,.registry-icon-rail .missing-item-icon{padding:2px;border:1px dashed #424849;background:#101314}.builder-picker .missing-item-icon,.builder-radio-grid .missing-item-icon{opacity:.66} -.database-registry-grid>a{min-height:180px;display:flex;flex-direction:column;padding:17px;border:1px solid var(--shd-border);border-top:2px solid #43494a;background:#111415;color:var(--shd-text);transition:border-color .15s ease,background .15s ease,transform .1s ease}.database-registry-grid>a:hover,.database-registry-grid>a:focus-visible{border-top-color:var(--shd-orange);background:#15191a}.database-registry-grid>a:active{transform:translateY(1px);filter:brightness(.88)}.database-registry-grid>a>span{margin-top:auto;color:#687073;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px} -.database-category-page{padding-top:34px}.database-category-command{display:flex;justify-content:space-between;align-items:end;gap:28px;padding:22px;border:1px solid var(--shd-border);border-top:2px solid var(--shd-orange);background:linear-gradient(115deg,#15191a,#0d1011)}.database-category-command h1{margin:8px 0 7px;font-size:clamp(30px,4vw,52px);line-height:.95}.database-category-command>div:first-child>p:last-child{max-width:720px;margin:0;color:#8d9597;font:12px/1.55 var(--shd-font-editorial)}.database-category-command>div:last-child{min-width:150px;display:grid;justify-items:end;gap:3px}.database-category-command>div:last-child strong{color:var(--shd-orange);font-size:32px;line-height:1}.database-category-command>div:last-child span{color:#687073;font-size:var(--shd-type-telemetry);letter-spacing:1px}.database-category-command>div:last-child a{margin-top:8px;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px} -.database-category-nav{display:grid;grid-template-columns:repeat(8,minmax(0,1fr));margin:10px 0;border:1px solid var(--shd-border);background:#0d1011}.database-category-nav>a{min-height:55px;display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:7px;padding:9px;border-left:1px solid var(--shd-border);color:#8e9698}.database-category-nav>a:first-child{border-left:0}.database-category-nav>a small{color:#525a5c;font-size:var(--shd-type-telemetry);letter-spacing:.6px}.database-category-nav>a span{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.35px}.database-category-nav>a b{color:#4f5759}.database-category-nav>a:hover,.database-category-nav>a:focus-visible{background:#171b1c;color:var(--shd-text)}.database-category-nav>a.active{box-shadow:inset 0 2px var(--shd-orange);background:#191814;color:var(--shd-orange)}.database-category-nav>a:active{filter:brightness(.85)} -.database-facet-panel{margin:10px 0;border:1px solid var(--shd-border);border-left:2px solid var(--shd-orange);background:#0d1011}.database-facet-panel>header{height:auto;min-height:43px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 11px;border-bottom:1px solid var(--shd-border);background:#15191a}.database-facet-panel>header>div{display:grid;gap:3px}.database-facet-panel>header small{color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.9px}.database-facet-panel>header strong{color:#727a7c;font-size:var(--shd-type-telemetry);letter-spacing:.7px}.database-facet-panel>header button{min-height:30px;padding:0 10px;border:1px solid #5d4324;background:#1c160d;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px;cursor:pointer}.database-facet-panel>header button:hover,.database-facet-panel>header button:focus-visible{border-color:var(--shd-orange);background:#261c0d}.database-facet-panel>header button:active{transform:translateY(1px);filter:brightness(.8)}.database-facet-groups{display:grid;grid-template-columns:1.2fr 1fr 1fr;gap:8px;padding:9px}.database-facet-group{min-width:0;margin:0;padding:7px;border:1px solid var(--shd-border);background:#111415}.database-facet-group legend{display:flex;align-items:center;gap:7px;padding:0 4px;color:#a7adaf;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.database-facet-group legend span{color:var(--shd-orange);font-size:var(--shd-type-telemetry)}.database-facet-group>div{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:4px}.database-facet-group>div>button{min-width:0;min-height:46px;display:flex;align-items:center;justify-content:center;gap:5px;padding:5px;border:1px solid #353b3c;background:#0d1011;color:#7f8789;cursor:pointer}.database-facet-group>div>button :is(.game-icon,img){width:28px;height:28px;flex:0 0 28px;object-fit:contain}.database-facet-group>div>button span{overflow:hidden;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.3px;text-overflow:ellipsis;white-space:nowrap}.database-facet-group>div>button:hover,.database-facet-group>div>button:focus-visible{border-color:#6a4c26;background:#18150f;color:#d0d3d1}.database-facet-group>div>button.selected{border-color:var(--shd-orange);box-shadow:inset 0 2px var(--shd-orange);background:#21190e;color:var(--shd-orange)}.database-facet-group>div>button:active{transform:translateY(1px);filter:brightness(.82)}.database-facet-group.searchable>div{max-height:150px;display:grid;grid-template-columns:1fr;overflow:auto}.database-facet-group.searchable>div>button{min-height:36px;justify-content:flex-start}.database-facet-group.searchable>div>button :is(img,.game-icon){width:25px;height:25px;flex-basis:25px}.database-facet-search{display:block;margin-bottom:5px}.database-facet-search input{width:100%;height:32px;margin:0;padding:0 8px;border:1px solid #3b4243;background:#0a0d0e;color:var(--shd-text);font-size:var(--shd-type-telemetry)}.database-facet-search input:focus{border-color:var(--shd-orange);outline:1px solid color-mix(in srgb,var(--shd-orange) 45%,transparent);outline-offset:1px} -.database-facet-groups.compact{grid-template-columns:1fr}.database-facet-groups.compact .database-facet-group>div{grid-template-columns:repeat(4,minmax(0,1fr))} -.database-category-page>.registry-pagination{margin:10px 0}.database-item-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:9px}.database-item-card{min-width:0;display:flex;flex-direction:column;border:1px solid var(--shd-border);border-top:2px solid #404748;background:#111415}.database-item-card>header{height:auto;min-height:78px;display:grid;grid-template-columns:58px minmax(0,1fr);align-items:center;gap:12px;padding:11px 13px;border-bottom:1px solid var(--shd-border)}.database-item-icon{width:58px;height:58px;display:grid;place-items:center;border:1px solid #343a3b;background:#0c0f10}.database-item-icon :is(img,.game-icon){width:52px;height:52px;object-fit:contain}.database-item-card>header small{display:block;margin-bottom:5px;color:#697174;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px;text-transform:uppercase}.database-item-card h2{overflow-wrap:anywhere;margin:0;font-size:14px;line-height:1.1}.database-item-badges{display:flex;flex-wrap:wrap;gap:5px;padding:8px 12px 0}.database-item-badges span{padding:5px 7px;border:1px solid #424849;background:#171b1c;color:#a5acae;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.6px;text-transform:uppercase}.database-item-badges .exotic{border-color:#74552a;background:#1a160d;color:var(--quality-high-end)}.database-item-badges :is(.standard,.specialized,.superior,.highend){border-left-width:2px}.database-item-badges .standard{border-left-color:var(--quality-standard)}.database-item-badges .specialized{border-left-color:var(--quality-specialized)}.database-item-badges .superior{border-left-color:var(--quality-superior)}.database-item-badges .highend{border-left-color:var(--quality-high-end)}.database-item-facts{display:grid;grid-template-columns:repeat(auto-fit,minmax(90px,1fr));margin:9px 12px 0;border:1px solid var(--shd-border)}.database-item-facts>div{min-width:0;display:grid;align-content:center;gap:4px;min-height:53px;padding:7px;border-left:1px solid var(--shd-border)}.database-item-facts>div:first-child{border-left:0}.database-item-facts span,.database-item-lines span{color:#626a6c;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.65px}.database-item-facts b{overflow-wrap:anywhere;color:#d2d6d3;font-size:var(--shd-type-telemetry);line-height:1.35}.database-item-lines{display:grid;gap:1px;margin:7px 12px 0;border-top:1px solid var(--shd-border)}.database-item-lines>div{display:grid;grid-template-columns:110px 1fr;gap:9px;padding:8px 0;border-bottom:1px solid var(--shd-border)}.database-item-lines b{overflow-wrap:anywhere;font-size:var(--shd-type-telemetry);line-height:1.4}.database-item-card>p{margin:9px 12px 0;padding:9px;border-left:2px solid var(--shd-orange);background:#0d1011;color:#a2a8aa;font:var(--shd-type-telemetry)/1.55 var(--shd-font-editorial)}.database-item-card>p.secondary-description{margin-top:5px;border-left-color:#5c6466}.database-item-card>:last-child{margin-bottom:12px}.database-empty{grid-column:1/-1;display:grid;justify-items:center;gap:9px;padding:70px 24px;border:1px solid var(--shd-border);background:#101314;text-align:center}.database-empty>span{color:var(--shd-orange);font-size:30px}.database-empty strong{font-size:var(--shd-type-telemetry);letter-spacing:1px}.database-empty p{margin:0;color:#7e8688;font:var(--shd-type-supporting)/1.5 var(--shd-font-editorial)} -.database-item-facts.stacked{grid-template-columns:1fr}.database-item-facts.stacked>div{grid-template-columns:82px minmax(0,1fr);align-items:center;min-height:43px;border-left:0;border-top:1px solid var(--shd-border)}.database-item-facts.stacked>div:first-child{border-top:0}.database-item-facts.stacked b{color:var(--shd-orange)} -.database-item-facts.stacked>div.primary{min-height:66px;grid-template-columns:minmax(130px,.7fr) minmax(0,1fr);border-left:3px solid var(--shd-orange);background:linear-gradient(90deg,#21190e,#101314)}.database-item-facts.stacked>div.primary span{padding-left:5px;color:#c89138;font-size:var(--shd-type-telemetry)}.database-item-facts.stacked>div.primary b{font-size:20px;letter-spacing:.7px} -.database-item-facts:empty{display:none}.database-item-lines.after-description{margin-top:6px}.database-item-lines.after-description>div{padding:7px 2px}.database-item-lines.after-description b{color:#9da4a6} -.database-item-card>p.data-description{position:relative;margin-top:6px;padding:10px 11px 10px 58px;border:1px solid var(--shd-border);border-left:2px solid #4b5355;background:#0e1112;color:#9ca3a5;font:var(--shd-type-telemetry)/1.55 var(--shd-font-sans)}.database-item-card>p.data-description::before{position:absolute;top:10px;left:10px;color:#858d8f;font:700 6px/1 var(--shd-font-sans);letter-spacing:.7px}.database-item-card>p.data-description:first-of-type::before{content:"TIER I"}.database-item-card>p.data-description.secondary-description{margin-top:4px;border-color:#57462c;border-left-color:var(--shd-orange);background:#12130f;color:#bcc1bf}.database-item-card>p.data-description.secondary-description::before{content:"TIER II";color:var(--shd-orange)} -.database-item-card>header{position:relative}.database-item-identity{min-width:0;padding-right:52px}.database-item-identity>small{margin:0}.database-header-status{position:absolute;top:10px;right:12px;display:flex;align-items:center;gap:6px}.database-header-status>.game-icon{width:19px;height:19px;object-fit:contain}.database-header-badge{flex:none;padding:3px 5px;border:1px solid #765426;background:#21180d;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.database-fact-value{min-width:0;display:flex;align-items:center;gap:8px}.database-fact-value .game-icon{flex:none;width:27px;height:27px;object-fit:contain}.database-fact-value small{color:#707779;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.database-item-facts.stacked>div.primary>span{font-size:var(--shd-type-telemetry);letter-spacing:.9px}.database-item-facts.stacked>div.primary .database-fact-value small{align-self:flex-end;margin-bottom:3px;color:#777d7e}.database-stat-meters{grid-column:2;min-width:0;display:grid;gap:7px}.database-stat-meters>div{display:grid;grid-template-columns:58px minmax(0,1fr) 25px;align-items:center;gap:8px}.database-stat-meters i{color:#737b7d;font-style:normal;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.6px}.database-stat-meters output{color:#c7ccca;font-size:var(--shd-type-telemetry);font-weight:900;text-align:right}.database-stat-meters progress{display:block;width:100%;min-width:40px;height:7px;border:0;background:#252a2b;accent-color:var(--shd-orange)}.database-stat-meters progress::-webkit-progress-bar{background:#252a2b}.database-stat-meters progress::-webkit-progress-value{background:linear-gradient(90deg,#86531b,var(--shd-orange))}.database-stat-meters progress::-moz-progress-bar{background:linear-gradient(90deg,#86531b,var(--shd-orange))} -.database-item-actions{display:flex;flex-wrap:wrap;gap:6px;margin:10px 12px 12px;padding-top:9px;border-top:1px solid var(--shd-border)}.database-item-actions>a{min-height:34px;display:inline-flex;align-items:center;justify-content:center;padding:0 11px;border:1px solid #74552a;background:#1b150c;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.65px}.database-item-actions>a:hover,.database-item-actions>a:focus-visible{border-color:var(--shd-orange);background:#261b0b;color:#ffd19b}.database-item-actions>a:active{transform:translateY(1px);filter:brightness(.82)} -.database-facet-group>div>button i{width:6px;height:6px;border:1px solid #596164;background:#171b1c;transform:rotate(45deg)}.database-facet-group>div>button.selected i{border-color:var(--shd-orange);background:var(--shd-orange);box-shadow:0 0 6px color-mix(in srgb,var(--shd-orange) 65%,transparent)} -.database-facet-group.searchable>div{max-height:220px}.database-facet-group.searchable>div>button:has(span small){min-height:58px;align-items:flex-start}.database-facet-group.searchable>div>button>span{display:grid;gap:3px;white-space:normal;text-align:left}.database-facet-group.searchable>div>button>span>small{display:-webkit-box;overflow:hidden;color:#747c7e;font:var(--shd-type-telemetry)/1.35 var(--shd-font-editorial);-webkit-box-orient:vertical;-webkit-line-clamp:2}.database-facet-group.searchable>div>button>span>small>b{margin-right:5px;color:var(--shd-orange);font:var(--shd-type-telemetry)/1 monospace;letter-spacing:.5px} -.database-category-page{overflow-x:clip}.database-category-command{width:100%;height:auto;min-height:132px}.database-category-command>*{min-width:0}.database-category-command h1{overflow-wrap:anywhere}.database-category-nav{min-width:0}.database-category-nav>a{min-width:0;grid-template-columns:auto minmax(0,1fr) auto}.community-index-hero{height:auto} -.database-item-icon-groups{display:grid;gap:8px;margin:8px 12px 0}.database-item-icon-groups>div{display:grid;grid-template-columns:110px minmax(0,1fr);align-items:center;gap:9px;padding-top:8px;border-top:1px solid var(--shd-border)}.database-item-icon-groups>div>span{color:#626a6c;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.65px}.item-icon-rail{min-width:0;display:flex;flex-wrap:wrap;gap:5px}.item-icon-rail>button{width:34px;height:34px;display:grid;place-items:center;padding:0;border:1px solid #394041;background:#0d1011;cursor:pointer}.item-icon-rail>button:hover,.item-icon-rail>button:focus-visible{border-color:var(--shd-orange);background:#211a0e}.item-icon-rail>button:active{transform:translateY(1px);filter:brightness(.82)}.item-icon-rail img{width:30px;height:30px;object-fit:contain}.item-icon-rail small{color:#687073;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.55px}.database-preview-backdrop{position:fixed;z-index:1200;inset:0;display:grid;place-items:center;padding:20px;background:rgba(3,5,5,.82);backdrop-filter:blur(3px)}.database-preview-dialog{width:min(520px,100%);max-height:calc(100vh - 40px);overflow:auto;border:1px solid #4b5254;border-top:2px solid var(--shd-orange);background:#0b0e0f;box-shadow:0 18px 60px #000}.database-preview-dialog>header{height:auto;display:flex;align-items:center;justify-content:space-between;padding:10px 12px;border-bottom:1px solid var(--shd-border);background:#15191a}.database-preview-dialog>header>div{display:grid;gap:3px}.database-preview-dialog>header small{color:#697174;font-size:var(--shd-type-telemetry);letter-spacing:.8px}.database-preview-dialog>header strong{font-size:var(--shd-type-telemetry);letter-spacing:.45px}.database-preview-dialog>header button{width:32px;height:32px;padding:0;border:1px solid #434a4c;background:#101314;color:#aab0b2;font-size:20px;line-height:1;cursor:pointer}.database-preview-dialog>header button:hover,.database-preview-dialog>header button:focus-visible{border-color:var(--shd-orange);color:var(--shd-orange)}.database-preview-dialog>.database-item-card{margin:10px;border-top-color:#404748} -@media(max-width:1200px){.database-category-nav{grid-template-columns:repeat(4,1fr)}.database-category-nav>a:nth-child(5){border-left:0}.database-category-nav>a:nth-child(n+5){border-top:1px solid var(--shd-border)}.database-facet-groups{grid-template-columns:1fr 1fr}.database-facet-group:first-child{grid-column:1/-1}.database-item-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:700px){.database-category-command{align-items:stretch;flex-direction:column}.database-category-command>div:last-child{justify-items:start}.database-category-nav{grid-template-columns:repeat(2,1fr)}.database-category-nav>a:nth-child(odd){border-left:0}.database-category-nav>a:nth-child(n+3){border-top:1px solid var(--shd-border)}.database-facet-panel>header{align-items:flex-start;flex-direction:column}.database-facet-groups{grid-template-columns:1fr}.database-facet-group:first-child{grid-column:auto}.database-facet-group>div,.database-facet-groups.compact .database-facet-group>div{grid-template-columns:repeat(2,minmax(0,1fr))}.database-item-grid{grid-template-columns:1fr}.database-item-card>header{grid-template-columns:52px minmax(0,1fr)}.database-item-icon{width:52px;height:52px}.database-item-icon :is(img,.game-icon){width:46px;height:46px}}@media(prefers-reduced-motion:reduce){.database-registry-grid>a{transition:none}} +.builder-command { + min-width: 0; + border: 1px solid var(--shd-border); + background: var(--shd-surface); +} +.builder-command .page-title { + min-width: 0; + padding: 14px 16px; + margin: 0; +} +.builder-command .page-title > div { + min-width: 0; +} +.builder-command .page-title > div:first-child { + flex: 1 1 460px; + min-width: min(460px, 42vw); +} +.builder-command .page-title input { + width: 100%; + max-width: none; + font-size: clamp(27px, 3.1vw, 46px); + letter-spacing: -0.03em; + text-transform: uppercase; +} +.builder-command .actions { + min-width: 0; + flex: 0 0 auto; + display: grid; + grid-template-columns: repeat(3, minmax(0, auto)); + align-items: center; + gap: 7px; +} +.builder-command .actions > span { + grid-column: 1/-1; + color: var(--shd-success); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.08em; + text-align: right; +} +.builder-command .builder-persistence { + padding: 10px 14px; +} +.builder-status { + min-width: 0; + flex-wrap: wrap; + gap: 5px 20px; + padding: 9px 14px; + border-top: 1px solid var(--shd-border); + background: #0c0f10; +} +.builder-status > span { + min-width: 0; + overflow-wrap: anywhere; +} +.builder-notes { + min-width: 0; + display: grid; + grid-template-columns: minmax(260px, 0.65fr) minmax(420px, 1.35fr); + gap: 8px; + padding: 10px 14px; + border-top: 1px solid var(--shd-border); + background: var(--shd-builder-canvas); +} +.builder-notes > * { + min-width: 0; +} +.builder-notes > label { + display: grid; + align-content: start; + gap: 6px; +} +.builder-notes > label > span { + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.08em; +} +.builder-notes > label small { + color: var(--shd-text-muted); +} +.builder-notes textarea { + width: 100%; + min-height: 150px; + padding: 11px; + border: 1px solid var(--shd-border); + border-radius: 0; + background: #0d0f10; + color: var(--shd-text); + font: var(--shd-type-body)/1.55 var(--shd-font-editorial); + resize: vertical; +} +.loadout-workspace { + min-width: 0; + grid-template-columns: minmax(300px, 350px) minmax(480px, 1fr) minmax( + 290px, + 340px + ); + gap: 6px; +} +.loadout-workspace > *, +.loadout-editor > *, +.loadout-summary > * { + min-width: 0; +} +.loadout-editor { + min-width: 0; + border-top-color: var(--shd-orange); + background: var(--shd-surface); +} +.loadout-editor-head { + min-width: 0; + gap: 10px; + margin: -12px -12px 10px; + padding: 11px 12px; + border-bottom-color: var(--shd-border); + background: #0c0f10; +} +.loadout-editor-head > div { + min-width: 0; +} +.loadout-editor-head h2, +.loadout-editor-head > b { + overflow-wrap: anywhere; +} +.loadout-editor-head > b { + flex: 0 1 auto; + text-align: right; +} +.loadout-summary { + min-width: 0; + background: var(--shd-surface); +} +.builder-picker-current > * { + min-width: 0; +} +.builder-picker-current span, +.builder-picker-panel button span { + min-width: 0; +} +.builder-section-title { + min-width: 0; + gap: 10px; +} +.builder-section-title > div { + min-width: 0; +} +.builder-section-title small { + overflow-wrap: anywhere; +} +.builder-section-title > b { + flex: 0 0 auto; + text-align: right; +} +.builder-ability-chip-panels.single { + grid-template-columns: 1fr; +} +.builder-ability-chip-panels.single section > div { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} +.builder-specialization-identity.ability-identity { + grid-template-columns: 54px minmax(0, 1fr) auto; +} +.builder-specialization-identity.ability-identity > .item-icon { + width: 50px; + height: 50px; +} +.shared-build-guide { + margin-top: 10px; + border: 1px solid var(--shd-border); + border-top: 2px solid var(--shd-orange); + background: var(--shd-panel); +} +.shared-build-guide > header { + display: flex; + justify-content: space-between; + padding: 9px 12px; + border-bottom: 1px solid var(--shd-border); + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.09em; +} +.shared-build-guide > header b { + color: var(--shd-orange); +} +.shared-build-summary { + max-width: 90ch; + margin: 0; + padding: 15px 18px; + border-bottom: 1px solid var(--shd-border); + color: var(--shd-text); + font: 16px/1.6 var(--shd-font-editorial); +} +.shared-build-guide > div { + padding: 18px; +} +.shared-build-guide h2 { + margin: 0 0 10px; + font-size: 18px; + letter-spacing: 0.04em; +} +@media (max-width: 1320px) { + .loadout-workspace { + grid-template-columns: minmax(270px, 320px) minmax(440px, 1fr); + } + .loadout-summary { + position: static; + grid-column: 1/3; + } + .summary-scroll { + max-height: none; + display: grid; + grid-template-columns: repeat(3, 1fr); + } +} +@media (max-width: 1050px) { + .builder-command .page-title { + align-items: stretch; + flex-direction: column; + } + .builder-command .page-title > div:first-child { + min-width: 0; + } + .builder-command .actions { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 7px; + } + .builder-command .actions > span { + grid-column: 1/-1; + } + .builder-command .actions > .secondary, + .builder-command .actions > .primary, + .builder-command .actions > a, + .builder-command .builder-save-form, + .builder-command .builder-save-form fieldset, + .builder-command .builder-save-form button { + width: 100%; + } +} +@media (max-width: 800px) { + .builder-command .page-title > div:first-child { + min-width: 0; + } + .builder-notes { + grid-template-columns: 1fr; + } + .loadout-workspace { + grid-template-columns: 1fr; + } + .loadout-workspace > .loadout-matrix, + .loadout-editor, + .loadout-summary { + grid-column: auto; + } + .builder-ability-chip-panels.single section > div { + grid-template-columns: 1fr; + } + .builder-specialization-identity.ability-identity { + grid-template-columns: 48px minmax(0, 1fr); + } + .builder-specialization-identity.ability-identity > b { + grid-column: 1/-1; + } + .summary-scroll { + display: block; + } +} +@media (max-width: 560px) { + .builder-command .actions { + width: 100%; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 7px; + } + .builder-command .actions > span, + .builder-command .actions > .primary, + .builder-command .actions > a.primary, + .builder-command .builder-save-form { + grid-column: 1/-1; + } + .builder-command .actions > .secondary, + .builder-command .actions > .primary, + .builder-command .actions > a, + .builder-command .builder-save-form, + .builder-command .builder-save-form fieldset, + .builder-command .builder-save-form button { + width: 100%; + } + .builder-status { + display: grid; + grid-template-columns: 1fr 1fr; + } + .builder-section-title { + align-items: flex-start; + flex-direction: column; + } + .builder-section-title > b { + align-self: flex-start; + text-align: left; + } + .builder-rolls.required > div { + grid-template-columns: 1fr; + } + .loadout-editor-head { + align-items: flex-start; + flex-wrap: wrap; + } + .loadout-editor-head > b { + margin-left: auto; + } + .summary-head { + gap: 10px; + } + .summary-head > b { + flex: 0 0 auto; + } +} +.weapon-mod-slot-config { + margin-top: 7px; + padding-top: 6px; + border-top: 1px solid var(--shd-border); +} +.weapon-mod-slot-config .assignment-picker { + margin-top: 5px; +} +.weapon-mod-editor { + border-top-color: var(--shd-orange); +} +.weapon-mod-identity { + display: grid; + grid-template-columns: minmax(300px, 1.4fr) 52px minmax(280px, 1fr); + align-items: end; + gap: 8px; +} +.weapon-mod-identity > .field, +.weapon-mod-identity > .segmented-choice { + margin: 0; +} +.weapon-mod-identity > .segmented-choice { + grid-column: 1/-1; +} +.weapon-mod-identity > .segmented-choice > div { + height: 38px; +} +.weapon-mod-assignment-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + margin-top: 10px; +} +.weapon-mod-secondary { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 7px; +} +.weapon-mod-quality-section, +.weapon-mod-effect-section { + margin-top: 10px; + padding: 9px; + border: 1px solid var(--shd-border); + background: #0d1011; +} +.weapon-mod-quality-section { + border-left: 2px solid var(--quality-high-end); +} +.weapon-mod-quality-table { + display: grid; + margin-top: 7px; + border: 1px solid var(--shd-border); +} +.weapon-mod-quality-row { + display: grid; + grid-template-columns: 110px repeat(4, minmax(180px, 1fr)); + align-items: center; + border-top: 1px solid var(--shd-border); +} +.weapon-mod-quality-row:first-child { + border-top: 0; +} +.weapon-mod-quality-row.heading { + min-height: 29px; + background: #111415; + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + letter-spacing: 0.8px; +} +.weapon-mod-quality-row > span, +.weapon-mod-quality-row > strong { + padding: 8px; +} +.weapon-mod-quality-row > strong { + font-size: var(--shd-type-telemetry); + letter-spacing: 0.7px; +} +.weapon-mod-quality-row > div { + display: grid; + grid-template-columns: 1fr 14px 1fr; + align-items: center; + gap: 4px; + padding: 5px; + border-left: 1px solid var(--shd-border); +} +.weapon-mod-quality-row input { + height: 30px; + margin: 0; + padding: 0 6px; + font-size: var(--shd-type-telemetry); +} +.weapon-mod-quality-row i { + color: #596164; + font-size: var(--shd-type-telemetry); + font-style: normal; + text-align: center; +} +.weapon-mod-preview { + display: grid; + grid-template-columns: minmax(360px, 0.8fr) minmax(420px, 1.2fr); + align-items: end; + gap: 8px; + margin-top: 8px; +} +.weapon-mod-preview > div:first-child { + display: grid; + grid-template-columns: 1fr 1.2fr; + align-items: end; + gap: 8px; +} +.weapon-mod-preview > div:first-child > label { + display: grid; + gap: 5px; + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.8px; +} +.weapon-mod-preview input[type='range'] { + height: 32px; + margin: 0; + padding: 0; + accent-color: var(--shd-orange); +} +.weapon-mod-preview-values { + display: grid; + grid-template-columns: 1.2fr 1fr 1fr; + border: 1px solid var(--shd-border); +} +.weapon-mod-preview-values > span { + min-height: 48px; + display: grid; + grid-template-columns: auto 1fr; + grid-template-rows: 1fr 1fr; + align-items: center; + gap: 2px 7px; + padding: 7px; + border-left: 1px solid var(--shd-border); +} +.weapon-mod-preview-values > span:first-child { + border-left: 0; +} +.weapon-mod-preview-values .game-icon { + grid-row: 1/3; + width: 28px; + height: 28px; +} +.weapon-mod-preview-values small { + align-self: end; + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); +} +.weapon-mod-preview-values b { + align-self: start; + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); +} +.weapon-mod-effects { + display: grid; + gap: 6px; + margin-top: 7px; +} +.weapon-mod-effects > div { + display: grid; + grid-template-columns: + 30px minmax(230px, 1fr) repeat(4, minmax(90px, 0.45fr)) + 30px; + align-items: end; + gap: 6px; + padding: 6px; + border: 1px solid var(--shd-border); + background: #101314; +} +.weapon-mod-effects > div > strong { + height: 100%; + display: grid; + place-items: center; + background: #171b1c; + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); +} +.weapon-mod-effects label { + display: grid; + gap: 4px; +} +.weapon-mod-effects label span { + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.5px; +} +.weapon-mod-effects input { + height: 32px; + margin: 0; + padding: 0 6px; +} +.weapon-mod-effects > div > button { + height: 32px; + border: 1px solid #563734; + background: #1d1212; + color: var(--shd-danger); + cursor: pointer; +} +.weapon-mod-effects > div > button:disabled { + opacity: 0.3; + cursor: not-allowed; +} +.weapon-mod-rule { + padding: 10px; + border: 1px solid #74552a; + border-left: 2px solid var(--quality-high-end); + background: #18150d; + color: #a89568; + font-size: var(--shd-type-telemetry); +} +.weapon-mod-rule b { + color: var(--quality-high-end); +} +.weapon-mod-row { + min-width: 1240px; + grid-template-columns: 1.15fr 0.55fr 0.65fr 1fr 1fr 0.55fr 0.5fr 0.3fr; +} +.weapon-mod-primary { + display: flex; + align-items: center; + gap: 5px; +} +.weapon-mod-primary .game-icon { + width: 22px; + height: 22px; +} +.weapon-mod-row > span small { + color: var(--shd-text-muted); +} +@media (max-width: 1100px) { + .weapon-mod-identity { + grid-template-columns: 1fr 52px 1fr; + } + .weapon-mod-assignment-grid, + .weapon-mod-preview { + grid-template-columns: 1fr; + } + .weapon-mod-quality-table { + overflow-x: auto; + } + .weapon-mod-quality-row { + min-width: 900px; + } + .weapon-mod-effects > div { + grid-template-columns: 30px 1fr 1fr 1fr; + } + .weapon-mod-effects label:nth-of-type(3), + .weapon-mod-effects label:nth-of-type(4), + .weapon-mod-effects > div > button { + grid-column: auto; + } +} +@media (max-width: 700px) { + .weapon-mod-identity, + .weapon-mod-assignment-grid, + .weapon-mod-secondary, + .weapon-mod-preview > div:first-child { + grid-template-columns: 1fr; + } + .weapon-mod-identity > .segmented-choice { + grid-column: auto; + } + .weapon-mod-identity > .segmented-choice > div { + height: auto; + grid-template-columns: repeat(2, 1fr); + } + .weapon-mod-identity > .segmented-choice span { + min-height: 44px; + } + .weapon-mod-effects > div { + grid-template-columns: 30px 1fr; + } + .weapon-mod-effects label, + .weapon-mod-effects > div > button { + grid-column: 2; + } + .weapon-mod-preview-values { + grid-template-columns: 1fr; + } + .weapon-mod-preview-values > span { + border-left: 0; + border-top: 1px solid var(--shd-border); + } + .weapon-mod-preview-values > span:first-child { + border-top: 0; + } +} +.weapon-mod-primary-types { + min-height: 58px; + display: grid; + align-content: center; + gap: 5px; + padding: 7px; + border: 1px solid var(--shd-border); + background: #0d1011; +} +.weapon-mod-primary-types > span { + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.8px; +} +.weapon-mod-primary-types > div { + display: flex; + gap: 8px; +} +.weapon-mod-primary-types b { + display: flex; + align-items: center; + gap: 3px; + color: #aab0b1; + font-size: var(--shd-type-telemetry); +} +.weapon-mod-primary-types .game-icon { + width: 18px; + height: 18px; +} +.weapon-mod-primary-types small { + color: #596164; + font-size: var(--shd-type-telemetry); +} +.weapon-mod-compatibility { + margin-top: 10px; +} +.weapon-mod-quality-row { + grid-template-columns: 110px repeat(2, minmax(220px, 1fr)); +} +.weapon-mod-quality-row.heading { + grid-template-columns: 110px repeat(2, minmax(220px, 1fr)); +} +.weapon-mod-preview-values { + grid-template-columns: 1.3fr 0.7fr; +} +.weapon-mod-preview-values .core-icon-stack { + grid-row: 1/3; +} +.weapon-mod-preview-values .core-icon-stack .game-icon { + grid-row: auto; + width: 22px; + height: 22px; +} +.weapon-mod-secondary-section { + margin-top: 10px; + padding: 9px; + border: 1px solid var(--shd-border); + border-left: 2px solid var(--quality-specialized); + background: #0d1011; +} +.weapon-mod-secondary-options { + display: grid; + gap: 6px; + margin-top: 7px; +} +.weapon-mod-secondary-options > div { + display: grid; + grid-template-columns: + 30px minmax(230px, 1fr) repeat(4, minmax(150px, 0.65fr)) + 30px; + align-items: end; + gap: 6px; + padding: 6px; + border: 1px solid var(--shd-border); + background: #101314; +} +.weapon-mod-secondary-options > div > strong { + height: 100%; + display: grid; + place-items: center; + background: #171b1c; + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); +} +.weapon-mod-secondary-options label { + display: grid; + gap: 4px; +} +.weapon-mod-secondary-options label > span { + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.5px; +} +.weapon-mod-secondary-options label > div { + display: grid; + grid-template-columns: 1fr 12px 1fr; + align-items: center; +} +.weapon-mod-secondary-options input { + height: 32px; + margin: 0; + padding: 0 5px; +} +.weapon-mod-secondary-options i { + color: #596164; + font-size: var(--shd-type-telemetry); + font-style: normal; + text-align: center; +} +.weapon-mod-secondary-options > div > button { + height: 32px; + border: 1px solid #563734; + background: #1d1212; + color: var(--shd-danger); + cursor: pointer; +} +.weapon-mod-secondary-options > div > button:disabled { + opacity: 0.3; + cursor: not-allowed; +} +@media (max-width: 1100px) { + .weapon-mod-quality-row, + .weapon-mod-quality-row.heading { + min-width: 580px; + } + .weapon-mod-secondary-options > div { + grid-template-columns: 30px 1fr 1fr 1fr; + } + .weapon-mod-secondary-options label:nth-of-type(3), + .weapon-mod-secondary-options label:nth-of-type(4) { + grid-column: auto; + } +} +@media (max-width: 700px) { + .weapon-mod-secondary-options > div { + grid-template-columns: 30px 1fr; + } + .weapon-mod-secondary-options label, + .weapon-mod-secondary-options > div > button { + grid-column: 2; + } +} +.weapon-mod-exotic-quality { + min-height: 58px; + display: flex; + align-items: center; + gap: 8px; + padding: 7px; + border: 1px solid #74552a; + border-left: 2px solid var(--quality-high-end); + background: #18150d; +} +.weapon-mod-exotic-quality .game-icon { + width: 34px; + height: 34px; +} +.weapon-mod-exotic-quality span { + display: grid; + gap: 3px; +} +.weapon-mod-exotic-quality b { + color: var(--quality-high-end); + font-size: var(--shd-type-telemetry); + letter-spacing: 0.8px; +} +.weapon-mod-exotic-quality small { + color: #a89568; + font-size: var(--shd-type-telemetry); + letter-spacing: 0.35px; +} +.weapon-mod-exotic-toggle > span { + width: 44px; + min-height: 42px; + padding: 0; +} +.weapon-mod-exotic-toggle input { + position: absolute; + width: 1px; + height: 1px; + margin: 0; + opacity: 0; + pointer-events: none; +} +.weapon-mod-exotic-toggle .game-icon { + width: 30px; + height: 30px; +} +.weapon-mod-compatibility .segmented-choice > div { + height: 48px; +} +.weapon-mod-compatibility .segmented-choice .game-icon { + width: 34px; + height: 34px; +} +.weapon-mod-exotic-section { + margin-top: 10px; + padding: 9px; + border: 1px solid #74552a; + border-left: 2px solid var(--quality-high-end); + background: #0d1011; +} +.weapon-mod-exotic-attributes { + display: grid; + gap: 6px; + margin-top: 7px; +} +.weapon-mod-exotic-attributes > div { + display: grid; + grid-template-columns: 30px minmax(240px, 1fr) 150px 150px 30px; + align-items: end; + gap: 6px; + padding: 6px; + border: 1px solid var(--shd-border); + background: #101314; +} +.weapon-mod-exotic-attributes > div > strong { + height: 100%; + display: grid; + place-items: center; + background: #171b1c; + color: var(--quality-high-end); + font-size: var(--shd-type-telemetry); +} +.weapon-mod-exotic-attributes label { + display: grid; + gap: 4px; + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.5px; +} +.weapon-mod-exotic-attributes input { + height: 32px; + margin: 0; + padding: 0 6px; +} +.weapon-mod-exotic-attributes > div > button { + height: 32px; + border: 1px solid #563734; + background: #1d1212; + color: var(--shd-danger); + cursor: pointer; +} +.weapon-mod-exotic-attributes > div > button:disabled { + opacity: 0.3; + cursor: not-allowed; +} +@media (max-width: 700px) { + .weapon-mod-exotic-attributes > div { + grid-template-columns: 30px 1fr; + } + .weapon-mod-exotic-attributes label, + .weapon-mod-exotic-attributes > div > button { + grid-column: 2; + } + .weapon-mod-compatibility .segmented-choice > div { + height: auto; + grid-template-columns: repeat(3, 1fr); + } + .weapon-mod-compatibility .segmented-choice span { + min-height: 50px; + } +} +.gear-mod-identity { + display: grid; + grid-template-columns: minmax(320px, 1fr) minmax(560px, 1.5fr); + align-items: end; + gap: 8px; +} +.gear-mod-identity > .field { + margin: 0; +} +.gear-mod-primary-types > div { + flex-wrap: wrap; +} +.gear-mod-row { + min-width: 1120px; + grid-template-columns: 1.2fr 1fr 0.9fr 1fr 0.55fr 0.55fr 0.3fr; +} +.gear-mod-row > span { + display: flex; + align-items: center; + gap: 6px; +} +.gear-mod-row > span small { + color: var(--shd-text-muted); +} +@media (max-width: 900px) { + .gear-mod-identity { + grid-template-columns: 1fr; + } +} +@media (max-width: 700px) { + .gear-mod-primary-types > div { + display: grid; + grid-template-columns: 1fr 1fr; + } +} +.missing-item-icon { + opacity: 0.7; + filter: saturate(0.75); +} +.icon-upload-preview .missing-item-icon { + width: 66px; + height: 66px; + opacity: 0.62; +} +.registry-icon-identity > .missing-item-icon, +.registry-icon-rail .missing-item-icon { + padding: 2px; + border: 1px dashed #424849; + background: #101314; +} +.builder-picker .missing-item-icon, +.builder-radio-grid .missing-item-icon { + opacity: 0.66; +} +.database-registry-grid > a { + min-height: 180px; + display: flex; + flex-direction: column; + padding: 17px; + border: 1px solid var(--shd-border); + border-top: 2px solid #43494a; + background: #111415; + color: var(--shd-text); + transition: + border-color 0.15s ease, + background 0.15s ease, + transform 0.1s ease; +} +.database-registry-grid > a:hover, +.database-registry-grid > a:focus-visible { + border-top-color: var(--shd-orange); + background: #15191a; +} +.database-registry-grid > a:active { + transform: translateY(1px); + filter: brightness(0.88); +} +.database-registry-grid > a > span { + margin-top: auto; + color: #687073; + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.8px; +} +.database-category-page { + padding-top: 34px; +} +.database-category-command { + display: flex; + justify-content: space-between; + align-items: end; + gap: 28px; + padding: 22px; + border: 1px solid var(--shd-border); + border-top: 2px solid var(--shd-orange); + background: linear-gradient(115deg, #15191a, #0d1011); +} +.database-category-command h1 { + margin: 8px 0 7px; + font-size: clamp(30px, 4vw, 52px); + line-height: 0.95; +} +.database-category-command > div:first-child > p:last-child { + max-width: 720px; + margin: 0; + color: #8d9597; + font: 12px/1.55 var(--shd-font-editorial); +} +.database-category-command > div:last-child { + min-width: 150px; + display: grid; + justify-items: end; + gap: 3px; +} +.database-category-command > div:last-child strong { + color: var(--shd-orange); + font-size: 32px; + line-height: 1; +} +.database-category-command > div:last-child span { + color: #687073; + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; +} +.database-category-command > div:last-child a { + margin-top: 8px; + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.8px; +} +.database-category-nav { + display: grid; + grid-template-columns: repeat(8, minmax(0, 1fr)); + margin: 10px 0; + border: 1px solid var(--shd-border); + background: #0d1011; +} +.database-category-nav > a { + min-height: 55px; + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 7px; + padding: 9px; + border-left: 1px solid var(--shd-border); + color: #8e9698; +} +.database-category-nav > a:first-child { + border-left: 0; +} +.database-category-nav > a small { + color: #525a5c; + font-size: var(--shd-type-telemetry); + letter-spacing: 0.6px; +} +.database-category-nav > a span { + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.35px; +} +.database-category-nav > a b { + color: #4f5759; +} +.database-category-nav > a:hover, +.database-category-nav > a:focus-visible { + background: #171b1c; + color: var(--shd-text); +} +.database-category-nav > a.active { + box-shadow: inset 0 2px var(--shd-orange); + background: #191814; + color: var(--shd-orange); +} +.database-category-nav > a:active { + filter: brightness(0.85); +} +.database-facet-panel { + margin: 10px 0; + border: 1px solid var(--shd-border); + border-left: 2px solid var(--shd-orange); + background: #0d1011; +} +.database-facet-panel > header { + height: auto; + min-height: 43px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 11px; + border-bottom: 1px solid var(--shd-border); + background: #15191a; +} +.database-facet-panel > header > div { + display: grid; + gap: 3px; +} +.database-facet-panel > header small { + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.9px; +} +.database-facet-panel > header strong { + color: #727a7c; + font-size: var(--shd-type-telemetry); + letter-spacing: 0.7px; +} +.database-facet-panel > header button { + min-height: 30px; + padding: 0 10px; + border: 1px solid #5d4324; + background: #1c160d; + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.7px; + cursor: pointer; +} +.database-facet-panel > header button:hover, +.database-facet-panel > header button:focus-visible { + border-color: var(--shd-orange); + background: #261c0d; +} +.database-facet-panel > header button:active { + transform: translateY(1px); + filter: brightness(0.8); +} +.database-facet-groups { + display: grid; + grid-template-columns: 1.2fr 1fr 1fr; + gap: 8px; + padding: 9px; +} +.database-facet-group { + min-width: 0; + margin: 0; + padding: 7px; + border: 1px solid var(--shd-border); + background: #111415; +} +.database-facet-group legend { + display: flex; + align-items: center; + gap: 7px; + padding: 0 4px; + color: #a7adaf; + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.7px; +} +.database-facet-group legend span { + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); +} +.database-facet-group > div { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 4px; +} +.database-facet-group > div > button { + min-width: 0; + min-height: 46px; + display: flex; + align-items: center; + justify-content: center; + gap: 5px; + padding: 5px; + border: 1px solid #353b3c; + background: #0d1011; + color: #7f8789; + cursor: pointer; +} +.database-facet-group > div > button :is(.game-icon, img) { + width: 28px; + height: 28px; + flex: 0 0 28px; + object-fit: contain; +} +.database-facet-group > div > button span { + overflow: hidden; + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.3px; + text-overflow: ellipsis; + white-space: nowrap; +} +.database-facet-group > div > button:hover, +.database-facet-group > div > button:focus-visible { + border-color: #6a4c26; + background: #18150f; + color: #d0d3d1; +} +.database-facet-group > div > button.selected { + border-color: var(--shd-orange); + box-shadow: inset 0 2px var(--shd-orange); + background: #21190e; + color: var(--shd-orange); +} +.database-facet-group > div > button:active { + transform: translateY(1px); + filter: brightness(0.82); +} +.database-facet-group.searchable > div { + max-height: 150px; + display: grid; + grid-template-columns: 1fr; + overflow: auto; +} +.database-facet-group.searchable > div > button { + min-height: 36px; + justify-content: flex-start; +} +.database-facet-group.searchable > div > button :is(img, .game-icon) { + width: 25px; + height: 25px; + flex-basis: 25px; +} +.database-facet-search { + display: block; + margin-bottom: 5px; +} +.database-facet-search input { + width: 100%; + height: 32px; + margin: 0; + padding: 0 8px; + border: 1px solid #3b4243; + background: #0a0d0e; + color: var(--shd-text); + font-size: var(--shd-type-telemetry); +} +.database-facet-search input:focus { + border-color: var(--shd-orange); + outline: 1px solid color-mix(in srgb, var(--shd-orange) 45%, transparent); + outline-offset: 1px; +} +.database-facet-groups.compact { + grid-template-columns: 1fr; +} +.database-facet-groups.compact .database-facet-group > div { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} +.database-category-page > .registry-pagination { + margin: 10px 0; +} +.database-item-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 9px; +} +.database-item-card { + min-width: 0; + display: flex; + flex-direction: column; + border: 1px solid var(--shd-border); + border-top: 2px solid #404748; + background: #111415; +} +.database-item-card > header { + height: auto; + min-height: 78px; + display: grid; + grid-template-columns: 58px minmax(0, 1fr); + align-items: center; + gap: 12px; + padding: 11px 13px; + border-bottom: 1px solid var(--shd-border); +} +.database-item-icon { + width: 58px; + height: 58px; + display: grid; + place-items: center; + border: 1px solid #343a3b; + background: #0c0f10; +} +.database-item-icon :is(img, .game-icon) { + width: 52px; + height: 52px; + object-fit: contain; +} +.database-item-card > header small { + display: block; + margin-bottom: 5px; + color: #697174; + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.8px; + text-transform: uppercase; +} +.database-item-card h2 { + overflow-wrap: anywhere; + margin: 0; + font-size: 14px; + line-height: 1.1; +} +.database-item-badges { + display: flex; + flex-wrap: wrap; + gap: 5px; + padding: 8px 12px 0; +} +.database-item-badges span { + padding: 5px 7px; + border: 1px solid #424849; + background: #171b1c; + color: #a5acae; + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.6px; + text-transform: uppercase; +} +.database-item-badges .exotic { + border-color: #74552a; + background: #1a160d; + color: var(--quality-high-end); +} +.database-item-badges :is(.standard, .specialized, .superior, .highend) { + border-left-width: 2px; +} +.database-item-badges .standard { + border-left-color: var(--quality-standard); +} +.database-item-badges .specialized { + border-left-color: var(--quality-specialized); +} +.database-item-badges .superior { + border-left-color: var(--quality-superior); +} +.database-item-badges .highend { + border-left-color: var(--quality-high-end); +} +.database-item-facts { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(90px, 1fr)); + margin: 9px 12px 0; + border: 1px solid var(--shd-border); +} +.database-item-facts > div { + min-width: 0; + display: grid; + align-content: center; + gap: 4px; + min-height: 53px; + padding: 7px; + border-left: 1px solid var(--shd-border); +} +.database-item-facts > div:first-child { + border-left: 0; +} +.database-item-facts span, +.database-item-lines span { + color: #626a6c; + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.65px; +} +.database-item-facts b { + overflow-wrap: anywhere; + color: #d2d6d3; + font-size: var(--shd-type-telemetry); + line-height: 1.35; +} +.database-item-lines { + display: grid; + gap: 1px; + margin: 7px 12px 0; + border-top: 1px solid var(--shd-border); +} +.database-item-lines > div { + display: grid; + grid-template-columns: 110px 1fr; + gap: 9px; + padding: 8px 0; + border-bottom: 1px solid var(--shd-border); +} +.database-item-lines b { + overflow-wrap: anywhere; + font-size: var(--shd-type-telemetry); + line-height: 1.4; +} +.database-item-card > p { + margin: 9px 12px 0; + padding: 9px; + border-left: 2px solid var(--shd-orange); + background: #0d1011; + color: #a2a8aa; + font: var(--shd-type-telemetry)/1.55 var(--shd-font-editorial); +} +.database-item-card > p.secondary-description { + margin-top: 5px; + border-left-color: #5c6466; +} +.database-item-card > :last-child { + margin-bottom: 12px; +} +.database-empty { + grid-column: 1/-1; + display: grid; + justify-items: center; + gap: 9px; + padding: 70px 24px; + border: 1px solid var(--shd-border); + background: #101314; + text-align: center; +} +.database-empty > span { + color: var(--shd-orange); + font-size: 30px; +} +.database-empty strong { + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; +} +.database-empty p { + margin: 0; + color: #7e8688; + font: var(--shd-type-supporting)/1.5 var(--shd-font-editorial); +} +.database-item-facts.stacked { + grid-template-columns: 1fr; +} +.database-item-facts.stacked > div { + grid-template-columns: 82px minmax(0, 1fr); + align-items: center; + min-height: 43px; + border-left: 0; + border-top: 1px solid var(--shd-border); +} +.database-item-facts.stacked > div:first-child { + border-top: 0; +} +.database-item-facts.stacked b { + color: var(--shd-orange); +} +.database-item-facts.stacked > div.primary { + min-height: 66px; + grid-template-columns: minmax(130px, 0.7fr) minmax(0, 1fr); + border-left: 3px solid var(--shd-orange); + background: linear-gradient(90deg, #21190e, #101314); +} +.database-item-facts.stacked > div.primary span { + padding-left: 5px; + color: #c89138; + font-size: var(--shd-type-telemetry); +} +.database-item-facts.stacked > div.primary b { + font-size: 20px; + letter-spacing: 0.7px; +} +.database-item-facts:empty { + display: none; +} +.database-item-lines.after-description { + margin-top: 6px; +} +.database-item-lines.after-description > div { + padding: 7px 2px; +} +.database-item-lines.after-description b { + color: #9da4a6; +} +.database-item-card > p.data-description { + position: relative; + margin-top: 6px; + padding: 10px 11px 10px 58px; + border: 1px solid var(--shd-border); + border-left: 2px solid #4b5355; + background: #0e1112; + color: #9ca3a5; + font: var(--shd-type-telemetry)/1.55 var(--shd-font-sans); +} +.database-item-card > p.data-description::before { + position: absolute; + top: 10px; + left: 10px; + color: #858d8f; + font: 700 6px/1 var(--shd-font-sans); + letter-spacing: 0.7px; +} +.database-item-card > p.data-description:first-of-type::before { + content: 'TIER I'; +} +.database-item-card > p.data-description.secondary-description { + margin-top: 4px; + border-color: #57462c; + border-left-color: var(--shd-orange); + background: #12130f; + color: #bcc1bf; +} +.database-item-card > p.data-description.secondary-description::before { + content: 'TIER II'; + color: var(--shd-orange); +} +.database-item-card > header { + position: relative; +} +.database-item-identity { + min-width: 0; + padding-right: 52px; +} +.database-item-identity > small { + margin: 0; +} +.database-header-status { + position: absolute; + top: 10px; + right: 12px; + display: flex; + align-items: center; + gap: 6px; +} +.database-header-status > .game-icon { + width: 19px; + height: 19px; + object-fit: contain; +} +.database-header-badge { + flex: none; + padding: 3px 5px; + border: 1px solid #765426; + background: #21180d; + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.7px; +} +.database-fact-value { + min-width: 0; + display: flex; + align-items: center; + gap: 8px; +} +.database-fact-value .game-icon { + flex: none; + width: 27px; + height: 27px; + object-fit: contain; +} +.database-fact-value small { + color: #707779; + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.7px; +} +.database-item-facts.stacked > div.primary > span { + font-size: var(--shd-type-telemetry); + letter-spacing: 0.9px; +} +.database-item-facts.stacked > div.primary .database-fact-value small { + align-self: flex-end; + margin-bottom: 3px; + color: #777d7e; +} +.database-stat-meters { + grid-column: 2; + min-width: 0; + display: grid; + gap: 7px; +} +.database-stat-meters > div { + display: grid; + grid-template-columns: 58px minmax(0, 1fr) 25px; + align-items: center; + gap: 8px; +} +.database-stat-meters i { + color: #737b7d; + font-style: normal; + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.6px; +} +.database-stat-meters output { + color: #c7ccca; + font-size: var(--shd-type-telemetry); + font-weight: 900; + text-align: right; +} +.database-stat-meters progress { + display: block; + width: 100%; + min-width: 40px; + height: 7px; + border: 0; + background: #252a2b; + accent-color: var(--shd-orange); +} +.database-stat-meters progress::-webkit-progress-bar { + background: #252a2b; +} +.database-stat-meters progress::-webkit-progress-value { + background: linear-gradient(90deg, #86531b, var(--shd-orange)); +} +.database-stat-meters progress::-moz-progress-bar { + background: linear-gradient(90deg, #86531b, var(--shd-orange)); +} +.database-item-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 10px 12px 12px; + padding-top: 9px; + border-top: 1px solid var(--shd-border); +} +.database-item-actions > a { + min-height: 34px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 11px; + border: 1px solid #74552a; + background: #1b150c; + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.65px; +} +.database-item-actions > a:hover, +.database-item-actions > a:focus-visible { + border-color: var(--shd-orange); + background: #261b0b; + color: #ffd19b; +} +.database-item-actions > a:active { + transform: translateY(1px); + filter: brightness(0.82); +} +.database-facet-group > div > button i { + width: 6px; + height: 6px; + border: 1px solid #596164; + background: #171b1c; + transform: rotate(45deg); +} +.database-facet-group > div > button.selected i { + border-color: var(--shd-orange); + background: var(--shd-orange); + box-shadow: 0 0 6px color-mix(in srgb, var(--shd-orange) 65%, transparent); +} +.database-facet-group.searchable > div { + max-height: 220px; +} +.database-facet-group.searchable > div > button:has(span small) { + min-height: 58px; + align-items: flex-start; +} +.database-facet-group.searchable > div > button > span { + display: grid; + gap: 3px; + white-space: normal; + text-align: left; +} +.database-facet-group.searchable > div > button > span > small { + display: -webkit-box; + overflow: hidden; + color: #747c7e; + font: var(--shd-type-telemetry)/1.35 var(--shd-font-editorial); + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} +.database-facet-group.searchable > div > button > span > small > b { + margin-right: 5px; + color: var(--shd-orange); + font: var(--shd-type-telemetry)/1 monospace; + letter-spacing: 0.5px; +} +.database-category-page { + overflow-x: clip; +} +.database-category-command { + width: 100%; + height: auto; + min-height: 132px; +} +.database-category-command > * { + min-width: 0; +} +.database-category-command h1 { + overflow-wrap: anywhere; +} +.database-category-nav { + min-width: 0; +} +.database-category-nav > a { + min-width: 0; + grid-template-columns: auto minmax(0, 1fr) auto; +} +.community-index-hero { + height: auto; +} +.database-item-icon-groups { + display: grid; + gap: 8px; + margin: 8px 12px 0; +} +.database-item-icon-groups > div { + display: grid; + grid-template-columns: 110px minmax(0, 1fr); + align-items: center; + gap: 9px; + padding-top: 8px; + border-top: 1px solid var(--shd-border); +} +.database-item-icon-groups > div > span { + color: #626a6c; + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.65px; +} +.item-icon-rail { + min-width: 0; + display: flex; + flex-wrap: wrap; + gap: 5px; +} +.item-icon-rail > button { + width: 34px; + height: 34px; + display: grid; + place-items: center; + padding: 0; + border: 1px solid #394041; + background: #0d1011; + cursor: pointer; +} +.item-icon-rail > button:hover, +.item-icon-rail > button:focus-visible { + border-color: var(--shd-orange); + background: #211a0e; +} +.item-icon-rail > button:active { + transform: translateY(1px); + filter: brightness(0.82); +} +.item-icon-rail img { + width: 30px; + height: 30px; + object-fit: contain; +} +.item-icon-rail small { + color: #687073; + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.55px; +} +.database-preview-backdrop { + position: fixed; + z-index: 1200; + inset: 0; + display: grid; + place-items: center; + padding: 20px; + background: rgba(3, 5, 5, 0.82); + backdrop-filter: blur(3px); +} +.database-preview-dialog { + width: min(520px, 100%); + max-height: calc(100vh - 40px); + overflow: auto; + border: 1px solid #4b5254; + border-top: 2px solid var(--shd-orange); + background: #0b0e0f; + box-shadow: 0 18px 60px #000; +} +.database-preview-dialog > header { + height: auto; + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + border-bottom: 1px solid var(--shd-border); + background: #15191a; +} +.database-preview-dialog > header > div { + display: grid; + gap: 3px; +} +.database-preview-dialog > header small { + color: #697174; + font-size: var(--shd-type-telemetry); + letter-spacing: 0.8px; +} +.database-preview-dialog > header strong { + font-size: var(--shd-type-telemetry); + letter-spacing: 0.45px; +} +.database-preview-dialog > header button { + width: 32px; + height: 32px; + padding: 0; + border: 1px solid #434a4c; + background: #101314; + color: #aab0b2; + font-size: 20px; + line-height: 1; + cursor: pointer; +} +.database-preview-dialog > header button:hover, +.database-preview-dialog > header button:focus-visible { + border-color: var(--shd-orange); + color: var(--shd-orange); +} +.database-preview-dialog > .database-item-card { + margin: 10px; + border-top-color: #404748; +} +@media (max-width: 1200px) { + .database-category-nav { + grid-template-columns: repeat(4, 1fr); + } + .database-category-nav > a:nth-child(5) { + border-left: 0; + } + .database-category-nav > a:nth-child(n + 5) { + border-top: 1px solid var(--shd-border); + } + .database-facet-groups { + grid-template-columns: 1fr 1fr; + } + .database-facet-group:first-child { + grid-column: 1/-1; + } + .database-item-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} +@media (max-width: 700px) { + .database-category-command { + align-items: stretch; + flex-direction: column; + } + .database-category-command > div:last-child { + justify-items: start; + } + .database-category-nav { + grid-template-columns: repeat(2, 1fr); + } + .database-category-nav > a:nth-child(odd) { + border-left: 0; + } + .database-category-nav > a:nth-child(n + 3) { + border-top: 1px solid var(--shd-border); + } + .database-facet-panel > header { + align-items: flex-start; + flex-direction: column; + } + .database-facet-groups { + grid-template-columns: 1fr; + } + .database-facet-group:first-child { + grid-column: auto; + } + .database-facet-group > div, + .database-facet-groups.compact .database-facet-group > div { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .database-item-grid { + grid-template-columns: 1fr; + } + .database-item-card > header { + grid-template-columns: 52px minmax(0, 1fr); + } + .database-item-icon { + width: 52px; + height: 52px; + } + .database-item-icon :is(img, .game-icon) { + width: 46px; + height: 46px; + } +} +@media (prefers-reduced-motion: reduce) { + .database-registry-grid > a { + transition: none; + } +} /* Public talent cards share one readable tier hierarchy in registries and quick views. */ -.database-item-card.talent-card>header{min-height:86px} - -@media(max-width:700px){.database-item-card>header:has(.database-item-render){grid-template-columns:52px 42px minmax(0,1fr);gap:8px}.database-item-card>header:has(.database-item-render) .database-item-render{width:52px;height:52px}.database-item-card>header:has(.database-item-render) .database-header-status{grid-column:3;position:static;justify-self:end}} -.database-item-card>header:has(.database-item-render){position:relative;isolation:isolate;grid-template-columns:44px minmax(0,1fr);min-height:104px;overflow:hidden;background:#101415}.database-item-card>header:has(.database-item-render)::after{content:"";position:absolute;z-index:1;inset:0;background:linear-gradient(90deg,rgba(8,11,12,.92) 0%,rgba(8,11,12,.76) 48%,rgba(8,11,12,.48) 100%);pointer-events:none}.database-item-card>header:has(.database-item-render) .database-item-render{position:absolute;z-index:0;inset:0;width:100%;height:100%;border:0;object-fit:cover;opacity:.82}.database-item-card>header:has(.database-item-render) .database-item-icon,.database-item-card>header:has(.database-item-render) .database-item-identity{position:relative;z-index:2}.database-item-card>header:has(.database-item-render) .database-header-status{position:absolute;z-index:2;right:12px}.database-item-card>header:has(.database-item-render) .database-item-icon{width:44px;height:44px}.database-item-card>header:has(.database-item-render) .database-item-icon :is(img,.game-icon){width:38px;height:38px} -.database-item-card:not(:has(.database-item-render)) .database-item-icon{width:48px;height:48px}.database-item-card:not(:has(.database-item-render)) .database-item-icon :is(img,.game-icon){width:42px;height:42px} -.database-item-facts.stacked>div.primary.armor-fact{border-left-color:#71aaa5;background:linear-gradient(90deg,#13211f,#101516)}.database-item-facts.stacked>div.primary.armor-fact>span{color:#8eb9b4}.database-item-facts.stacked>div.primary.armor-fact b{color:#a5d1ca} -@media(max-width:700px){.database-item-card>header:has(.database-item-render){grid-template-columns:42px minmax(0,1fr);min-height:92px}.database-item-card>header:has(.database-item-render) .database-item-icon{width:42px;height:42px}.database-item-card>header:has(.database-item-render) .database-item-icon :is(img,.game-icon){width:36px;height:36px}.database-item-card:not(:has(.database-item-render)) .database-item-icon{width:44px;height:44px}.database-item-card:not(:has(.database-item-render)) .database-item-icon :is(img,.game-icon){width:38px;height:38px}} -.database-item-card.talent-card h2{font-size:18px;line-height:1.1} -.database-item-card.talent-card>header small,.database-item-card.talent-card .database-item-lines span{color:var(--shd-text-muted)} -.database-talent-tiers{display:grid;gap:var(--shd-gap-sm);margin:10px 12px 0} -.database-talent-tiers>section{display:grid;grid-template-columns:54px minmax(0,1fr);align-items:start;border:1px solid var(--shd-border);background:var(--shd-bg)} -.database-talent-tiers>section>span{align-self:stretch;display:grid;place-items:start center;padding:11px 7px;border-right:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px} -.database-talent-tiers>section>p{margin:0;padding:12px 14px;color:var(--shd-text);font:600 var(--shd-type-body)/1.5 var(--shd-font-sans)} -.database-talent-tiers>section:last-child{border-left:2px solid var(--shd-orange);background:var(--shd-surface-raised)} -.database-talent-tiers>section:last-child>span{color:var(--shd-orange)} -.talent-card .database-item-lines.after-description{margin-top:10px} -.talent-card .database-item-lines.after-description>div{grid-template-columns:132px minmax(0,1fr);padding:9px 2px} -.talent-card .database-item-lines.after-description b{color:var(--shd-text);font-size:var(--shd-type-supporting);font-weight:600;line-height:1.45} -.talent-card .database-item-actions{margin-top:auto} -.database-preview-dialog:has(.talent-card){width:min(620px,100%)} -@media(max-width:700px){ - .item-icon-rail>button{width:44px;height:44px} - .database-preview-dialog>header button{width:44px;height:44px} - .talent-card .database-item-lines.after-description>div{grid-template-columns:1fr;gap:5px} +.database-item-card.talent-card > header { + min-height: 86px; } -@media(max-width:480px){ - .database-talent-tiers>section{grid-template-columns:1fr} - .database-talent-tiers>section>span{place-items:center start;padding:7px 10px;border-right:0;border-bottom:1px solid var(--shd-border)} + +@media (max-width: 700px) { + .database-item-card > header:has(.database-item-render) { + grid-template-columns: 52px 42px minmax(0, 1fr); + gap: 8px; + } + .database-item-card + > header:has(.database-item-render) + .database-item-render { + width: 52px; + height: 52px; + } + .database-item-card + > header:has(.database-item-render) + .database-header-status { + grid-column: 3; + position: static; + justify-self: end; + } +} +.database-item-card > header:has(.database-item-render) { + position: relative; + isolation: isolate; + grid-template-columns: 44px minmax(0, 1fr); + min-height: 104px; + overflow: hidden; + background: #101415; +} +.database-item-card > header:has(.database-item-render)::after { + content: ''; + position: absolute; + z-index: 1; + inset: 0; + background: linear-gradient( + 90deg, + rgba(8, 11, 12, 0.92) 0%, + rgba(8, 11, 12, 0.76) 48%, + rgba(8, 11, 12, 0.48) 100% + ); + pointer-events: none; +} +.database-item-card > header:has(.database-item-render) .database-item-render { + position: absolute; + z-index: 0; + inset: 0; + width: 100%; + height: 100%; + border: 0; + object-fit: cover; + opacity: 0.82; +} +.database-item-card > header:has(.database-item-render) .database-item-icon, +.database-item-card + > header:has(.database-item-render) + .database-item-identity { + position: relative; + z-index: 2; +} +.database-item-card + > header:has(.database-item-render) + .database-header-status { + position: absolute; + z-index: 2; + right: 12px; +} +.database-item-card > header:has(.database-item-render) .database-item-icon { + width: 44px; + height: 44px; +} +.database-item-card + > header:has(.database-item-render) + .database-item-icon + :is(img, .game-icon) { + width: 38px; + height: 38px; +} +.database-item-card:not(:has(.database-item-render)) .database-item-icon { + width: 48px; + height: 48px; +} +.database-item-card:not(:has(.database-item-render)) + .database-item-icon + :is(img, .game-icon) { + width: 42px; + height: 42px; +} +.database-item-facts.stacked > div.primary.armor-fact { + border-left-color: var(--shd-armor-border); + background: linear-gradient(90deg, var(--shd-armor-surface), #101516); +} +.database-item-facts.stacked > div.primary.armor-fact > span { + color: var(--shd-armor-label); +} +.database-item-facts.stacked > div.primary.armor-fact b { + color: var(--shd-armor-text); +} +@media (max-width: 700px) { + .database-item-card > header:has(.database-item-render) { + grid-template-columns: 42px minmax(0, 1fr); + min-height: 92px; + } + .database-item-card > header:has(.database-item-render) .database-item-icon { + width: 42px; + height: 42px; + } + .database-item-card + > header:has(.database-item-render) + .database-item-icon + :is(img, .game-icon) { + width: 36px; + height: 36px; + } + .database-item-card:not(:has(.database-item-render)) .database-item-icon { + width: 44px; + height: 44px; + } + .database-item-card:not(:has(.database-item-render)) + .database-item-icon + :is(img, .game-icon) { + width: 38px; + height: 38px; + } +} +.database-item-card.talent-card h2 { + font-size: 18px; + line-height: 1.1; +} +.database-item-card.talent-card > header small, +.database-item-card.talent-card .database-item-lines span { + color: var(--shd-text-muted); +} +.database-talent-tiers { + display: grid; + gap: var(--shd-gap-sm); + margin: 10px 12px 0; +} +.database-talent-tiers > section { + display: grid; + grid-template-columns: 54px minmax(0, 1fr); + align-items: start; + border: 1px solid var(--shd-border); + background: var(--shd-bg); +} +.database-talent-tiers > section > span { + align-self: stretch; + display: grid; + place-items: start center; + padding: 11px 7px; + border-right: 1px solid var(--shd-border); + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.8px; +} +.database-talent-tiers > section > p { + margin: 0; + padding: 12px 14px; + color: var(--shd-text); + font: 600 var(--shd-type-body)/1.5 var(--shd-font-sans); +} +.database-talent-tiers > section:last-child { + border-left: 2px solid var(--shd-orange); + background: var(--shd-surface-raised); +} +.database-talent-tiers > section:last-child > span { + color: var(--shd-orange); +} +.talent-card .database-item-lines.after-description { + margin-top: 10px; +} +.talent-card .database-item-lines.after-description > div { + grid-template-columns: 132px minmax(0, 1fr); + padding: 9px 2px; +} +.talent-card .database-item-lines.after-description b { + color: var(--shd-text); + font-size: var(--shd-type-supporting); + font-weight: 600; + line-height: 1.45; +} +.talent-card .database-item-actions { + margin-top: auto; +} +.database-preview-dialog:has(.talent-card) { + width: min(620px, 100%); +} +@media (max-width: 700px) { + .item-icon-rail > button { + width: 44px; + height: 44px; + } + .database-preview-dialog > header button { + width: 44px; + height: 44px; + } + .talent-card .database-item-lines.after-description > div { + grid-template-columns: 1fr; + gap: 5px; + } +} +@media (max-width: 480px) { + .database-talent-tiers > section { + grid-template-columns: 1fr; + } + .database-talent-tiers > section > span { + place-items: center start; + padding: 7px 10px; + border-right: 0; + border-bottom: 1px solid var(--shd-border); + } } /* Authenticated desktop identity and account actions */ -.desktop-agent-menu{position:relative}.site-header .desktop-agent-trigger{display:grid;grid-template-columns:36px minmax(110px,1fr) auto 12px;align-items:center;gap:10px;min-width:218px;min-height:50px;padding:6px 9px;border:1px solid var(--shd-border);background:#111415;color:var(--shd-text);text-align:left}.site-header .desktop-agent-trigger:hover,.site-header .desktop-agent-trigger:focus-visible,.site-header .desktop-agent-trigger[aria-expanded=true]{border-color:var(--shd-orange);background:#171b1c;color:var(--shd-text)}.desktop-agent-trigger>img,.desktop-agent-panel header>img,.mobile-agent-state>img{width:36px;height:36px;border:1px solid var(--shd-border);background:#191d1e;object-fit:cover}.desktop-agent-trigger>span{display:grid;min-width:0;line-height:1}.desktop-agent-trigger>span small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:1px}.desktop-agent-trigger>span b{overflow:hidden;margin-top:5px;font-size:var(--shd-type-telemetry);letter-spacing:1px;text-overflow:ellipsis;text-transform:uppercase;white-space:nowrap}.desktop-agent-trigger>em{display:inline-flex;align-items:center;justify-content:center;min-width:22px;height:22px;padding:0 5px;border:1px solid #a86d16;background:#241b0d;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-style:normal;font-weight:900}.desktop-agent-trigger>strong{color:var(--shd-orange);font:800 14px/1 monospace;text-align:center}.desktop-agent-panel{position:absolute;z-index:80;top:calc(100% + 10px);right:0;width:min(290px,calc(100vw - 32px));border:1px solid #4a5051;background:#0f1213;box-shadow:0 18px 46px #000a}.desktop-agent-panel:before{position:absolute;top:-6px;right:18px;width:10px;height:10px;border-top:1px solid var(--shd-orange);border-left:1px solid var(--shd-orange);background:#111415;content:"";transform:rotate(45deg)}.desktop-agent-panel header{height:auto;min-height:68px;display:grid;grid-template-columns:44px minmax(0,1fr);align-items:center;gap:12px;padding:11px 12px;border-bottom:1px solid var(--shd-border);background:linear-gradient(90deg,#281e0e66,transparent 70%),#111415}.desktop-agent-panel header>img{width:44px;height:44px}.desktop-agent-panel header>div{display:grid;min-width:0;gap:3px}.desktop-agent-panel header small,.desktop-agent-panel header span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:1px}.desktop-agent-panel header b{overflow:hidden;font-size:var(--shd-type-telemetry);letter-spacing:1px;text-overflow:ellipsis;text-transform:uppercase;white-space:nowrap}.desktop-agent-panel>a{min-height:53px;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;padding:8px 12px;border-bottom:1px solid var(--shd-border-soft);color:var(--shd-text)}.desktop-agent-panel>a>span{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1px}.desktop-agent-panel>a>b{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:.7px}.desktop-agent-panel>a:hover,.desktop-agent-panel>a:focus-visible,.desktop-agent-panel>a.active{background:#171b1c;color:var(--shd-orange)}.desktop-agent-panel>a:hover>b,.desktop-agent-panel>a:focus-visible>b,.desktop-agent-panel>a.active>b{color:var(--shd-orange)}.desktop-agent-panel form,.desktop-agent-panel fieldset{margin:0;padding:0;border:0}.site-header .desktop-agent-panel .desktop-agent-sign-out{width:100%;min-height:45px;padding:8px 12px;border:0;background:#111415;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1px;text-align:right;cursor:pointer}.site-header .desktop-agent-panel .desktop-agent-sign-out:hover,.site-header .desktop-agent-panel .desktop-agent-sign-out:focus-visible{background:#211514;color:var(--shd-danger)}.site-header .desktop-agent-panel button:disabled{cursor:wait;opacity:.62}@media(max-width:900px){.mobile-agent-state{grid-template-columns:36px minmax(0,1fr);column-gap:10px}.mobile-agent-state>img{grid-row:1/3;align-self:center}.mobile-agent-state>span,.mobile-agent-state>strong{grid-column:2}}@media(prefers-reduced-motion:reduce){.desktop-agent-trigger,.desktop-agent-panel a,.desktop-agent-panel button{transition:none}} +.desktop-agent-menu { + position: relative; +} +.site-header .desktop-agent-trigger { + display: grid; + grid-template-columns: 36px minmax(110px, 1fr) auto 12px; + align-items: center; + gap: 10px; + min-width: 218px; + min-height: 50px; + padding: 6px 9px; + border: 1px solid var(--shd-border); + background: #111415; + color: var(--shd-text); + text-align: left; +} +.site-header .desktop-agent-trigger:hover, +.site-header .desktop-agent-trigger:focus-visible, +.site-header .desktop-agent-trigger[aria-expanded='true'] { + border-color: var(--shd-orange); + background: #171b1c; + color: var(--shd-text); +} +.desktop-agent-trigger > img, +.desktop-agent-panel header > img, +.mobile-agent-state > img { + width: 36px; + height: 36px; + border: 1px solid var(--shd-border); + background: #191d1e; + object-fit: cover; +} +.desktop-agent-trigger > span { + display: grid; + min-width: 0; + line-height: 1; +} +.desktop-agent-trigger > span small { + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; +} +.desktop-agent-trigger > span b { + overflow: hidden; + margin-top: 5px; + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; + text-overflow: ellipsis; + text-transform: uppercase; + white-space: nowrap; +} +.desktop-agent-trigger > em { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 22px; + height: 22px; + padding: 0 5px; + border: 1px solid #a86d16; + background: #241b0d; + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); + font-style: normal; + font-weight: 900; +} +.desktop-agent-trigger > strong { + color: var(--shd-orange); + font: 800 14px/1 monospace; + text-align: center; +} +.desktop-agent-panel { + position: absolute; + z-index: 80; + top: calc(100% + 10px); + right: 0; + width: min(290px, calc(100vw - 32px)); + border: 1px solid #4a5051; + background: #0f1213; + box-shadow: 0 18px 46px #000a; +} +.desktop-agent-panel:before { + position: absolute; + top: -6px; + right: 18px; + width: 10px; + height: 10px; + border-top: 1px solid var(--shd-orange); + border-left: 1px solid var(--shd-orange); + background: #111415; + content: ''; + transform: rotate(45deg); +} +.desktop-agent-panel header { + height: auto; + min-height: 68px; + display: grid; + grid-template-columns: 44px minmax(0, 1fr); + align-items: center; + gap: 12px; + padding: 11px 12px; + border-bottom: 1px solid var(--shd-border); + background: linear-gradient(90deg, #281e0e66, transparent 70%), #111415; +} +.desktop-agent-panel header > img { + width: 44px; + height: 44px; +} +.desktop-agent-panel header > div { + display: grid; + min-width: 0; + gap: 3px; +} +.desktop-agent-panel header small, +.desktop-agent-panel header span { + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; +} +.desktop-agent-panel header b { + overflow: hidden; + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; + text-overflow: ellipsis; + text-transform: uppercase; + white-space: nowrap; +} +.desktop-agent-panel > a { + min-height: 53px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + padding: 8px 12px; + border-bottom: 1px solid var(--shd-border-soft); + color: var(--shd-text); +} +.desktop-agent-panel > a > span { + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 1px; +} +.desktop-agent-panel > a > b { + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + letter-spacing: 0.7px; +} +.desktop-agent-panel > a:hover, +.desktop-agent-panel > a:focus-visible, +.desktop-agent-panel > a.active { + background: #171b1c; + color: var(--shd-orange); +} +.desktop-agent-panel > a:hover > b, +.desktop-agent-panel > a:focus-visible > b, +.desktop-agent-panel > a.active > b { + color: var(--shd-orange); +} +.desktop-agent-panel form, +.desktop-agent-panel fieldset { + margin: 0; + padding: 0; + border: 0; +} +.site-header .desktop-agent-panel .desktop-agent-sign-out { + width: 100%; + min-height: 45px; + padding: 8px 12px; + border: 0; + background: #111415; + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 1px; + text-align: right; + cursor: pointer; +} +.site-header .desktop-agent-panel .desktop-agent-sign-out:hover, +.site-header .desktop-agent-panel .desktop-agent-sign-out:focus-visible { + background: #211514; + color: var(--shd-danger); +} +.site-header .desktop-agent-panel button:disabled { + cursor: wait; + opacity: 0.62; +} +@media (max-width: 900px) { + .mobile-agent-state { + grid-template-columns: 36px minmax(0, 1fr); + column-gap: 10px; + } + .mobile-agent-state > img { + grid-row: 1/3; + align-self: center; + } + .mobile-agent-state > span, + .mobile-agent-state > strong { + grid-column: 2; + } +} +@media (prefers-reduced-motion: reduce) { + .desktop-agent-trigger, + .desktop-agent-panel a, + .desktop-agent-panel button { + transition: none; + } +} -.desktop-agent-trigger>img,.desktop-agent-panel header>img,.mobile-agent-state>img{border-color:var(--shd-orange);border-radius:50%;box-shadow:0 0 0 3px #0a0c0d,0 0 0 4px #f6a82144} -@media(max-width:900px){.mobile-navigation{overflow-x:hidden}.mobile-navigation>a span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}} -.mobile-nav-toggle{position:relative}.mobile-nav-unread{position:absolute;top:-7px;right:-7px;min-width:18px;height:18px;display:grid;place-items:center;padding:0 4px;border:1px solid #e8a43a;background:var(--shd-orange);box-shadow:0 0 0 2px #0a0c0d;color:#111;font-size:var(--shd-type-telemetry);font-style:normal;font-weight:900;line-height:1;pointer-events:none} -.oversight-preference-heading{display:flex;align-items:end;justify-content:space-between;gap:16px;margin-top:18px;padding:14px 9px 9px;border-top:1px solid var(--shd-border)}.oversight-preference-heading h3{margin:4px 0 0;font-size:13px}.oversight-preference-heading>span{color:#8ecce5;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.oversight-preference-form{display:grid;gap:7px}.oversight-preference-row{display:grid;grid-template-columns:minmax(190px,.65fr) minmax(420px,1.35fr);align-items:center;gap:10px;padding:9px;border:1px solid var(--shd-border);background:#0d1011}.oversight-preference-row>div:first-child{display:grid;gap:4px}.oversight-preference-row>div:first-child b{font-size:var(--shd-type-telemetry);letter-spacing:.7px}.oversight-preference-row>div:first-child small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:.5px}.oversight-preference-row fieldset{margin:0}@media(max-width:760px){.oversight-preference-heading{align-items:start;flex-direction:column}.oversight-preference-row{grid-template-columns:1fr}.oversight-preference-row fieldset>div{grid-template-columns:1fr 1fr}} -.system-message-editor{margin-bottom:10px}.system-message-editor-grid{display:grid;grid-template-columns:1.1fr 1.1fr .8fr;gap:7px;padding:9px;border:1px solid var(--shd-border);background:#0d1011}.system-message-editor-grid .wide{grid-column:span 1}.system-message-editor-grid .full{grid-column:1/-1}.system-message-editor-grid textarea{min-height:126px;resize:vertical}.system-message-editor-grid small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry)}.system-message-enabled{display:grid;grid-template-columns:1fr auto;align-items:center;padding:8px;border:1px solid var(--shd-border);background:#111415}.system-message-enabled>span{grid-column:1/-1;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.system-message-enabled input{width:18px;margin:0;accent-color:var(--shd-orange)}.system-message-enabled b{color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.system-message-actions{display:grid;margin-top:7px;border:1px solid var(--shd-border)}.system-message-actions>div{min-height:38px;display:grid;grid-template-columns:34px minmax(150px,.7fr) minmax(220px,1.3fr);align-items:center;gap:6px;padding:5px 7px;border-top:1px solid var(--shd-border-soft)}.system-message-actions>div:first-child{grid-template-columns:1fr auto;border-top:0;background:#0d1011}.system-message-actions>div:first-child span{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.system-message-actions>div:first-child small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry)}.system-message-actions b{color:#5f6769;font-size:var(--shd-type-telemetry)}.system-message-actions input{height:30px;margin:0;font-size:var(--shd-type-telemetry)}.system-message-table .dashboard-row{grid-template-columns:1.3fr .7fr .7fr .7fr 64px}.system-message-table .dashboard-row>div{display:grid;min-width:0;gap:3px}.system-message-table .dashboard-row small{overflow:hidden;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);text-overflow:ellipsis;white-space:nowrap}.notification-actions{flex-wrap:wrap}.notification-detail-actions{flex-wrap:wrap}.notification-detail-actions>a{min-height:38px;display:inline-flex;align-items:center;justify-content:center;padding:0 12px}@media(max-width:900px){.system-message-editor-grid{grid-template-columns:1fr 1fr}.system-message-editor-grid .full{grid-column:1/-1}.system-message-table .dashboard-row{grid-template-columns:1fr}.system-message-table .dashboard-row.heading{display:none}.system-message-table .dashboard-row>button{min-height:44px}}@media(max-width:600px){.system-message-editor-grid{grid-template-columns:1fr}.system-message-editor-grid .full{grid-column:auto}.system-message-actions>div,.system-message-actions>div:first-child{grid-template-columns:1fr}.system-message-actions>div>b{display:none}.notification-detail-actions>a{width:100%}} +.desktop-agent-trigger > img, +.desktop-agent-panel header > img, +.mobile-agent-state > img { + border-color: var(--shd-orange); + border-radius: 50%; + box-shadow: + 0 0 0 3px #0a0c0d, + 0 0 0 4px #f6a82144; +} +@media (max-width: 900px) { + .mobile-navigation { + overflow-x: hidden; + } + .mobile-navigation > a span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} +.mobile-nav-toggle { + position: relative; +} +.mobile-nav-unread { + position: absolute; + top: -7px; + right: -7px; + min-width: 18px; + height: 18px; + display: grid; + place-items: center; + padding: 0 4px; + border: 1px solid #e8a43a; + background: var(--shd-orange); + box-shadow: 0 0 0 2px #0a0c0d; + color: #111; + font-size: var(--shd-type-telemetry); + font-style: normal; + font-weight: 900; + line-height: 1; + pointer-events: none; +} +.oversight-preference-heading { + display: flex; + align-items: end; + justify-content: space-between; + gap: 16px; + margin-top: 18px; + padding: 14px 9px 9px; + border-top: 1px solid var(--shd-border); +} +.oversight-preference-heading h3 { + margin: 4px 0 0; + font-size: 13px; +} +.oversight-preference-heading > span { + color: #8ecce5; + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.8px; +} +.oversight-preference-form { + display: grid; + gap: 7px; +} +.oversight-preference-row { + display: grid; + grid-template-columns: minmax(190px, 0.65fr) minmax(420px, 1.35fr); + align-items: center; + gap: 10px; + padding: 9px; + border: 1px solid var(--shd-border); + background: #0d1011; +} +.oversight-preference-row > div:first-child { + display: grid; + gap: 4px; +} +.oversight-preference-row > div:first-child b { + font-size: var(--shd-type-telemetry); + letter-spacing: 0.7px; +} +.oversight-preference-row > div:first-child small { + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + letter-spacing: 0.5px; +} +.oversight-preference-row fieldset { + margin: 0; +} +@media (max-width: 760px) { + .oversight-preference-heading { + align-items: start; + flex-direction: column; + } + .oversight-preference-row { + grid-template-columns: 1fr; + } + .oversight-preference-row fieldset > div { + grid-template-columns: 1fr 1fr; + } +} +.system-message-editor { + margin-bottom: 10px; +} +.system-message-editor-grid { + display: grid; + grid-template-columns: 1.1fr 1.1fr 0.8fr; + gap: 7px; + padding: 9px; + border: 1px solid var(--shd-border); + background: #0d1011; +} +.system-message-editor-grid .wide { + grid-column: span 1; +} +.system-message-editor-grid .full { + grid-column: 1/-1; +} +.system-message-editor-grid textarea { + min-height: 126px; + resize: vertical; +} +.system-message-editor-grid small { + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); +} +.system-message-enabled { + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + padding: 8px; + border: 1px solid var(--shd-border); + background: #111415; +} +.system-message-enabled > span { + grid-column: 1/-1; + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + letter-spacing: 0.8px; +} +.system-message-enabled input { + width: 18px; + margin: 0; + accent-color: var(--shd-orange); +} +.system-message-enabled b { + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); + letter-spacing: 0.8px; +} +.system-message-actions { + display: grid; + margin-top: 7px; + border: 1px solid var(--shd-border); +} +.system-message-actions > div { + min-height: 38px; + display: grid; + grid-template-columns: 34px minmax(150px, 0.7fr) minmax(220px, 1.3fr); + align-items: center; + gap: 6px; + padding: 5px 7px; + border-top: 1px solid var(--shd-border-soft); +} +.system-message-actions > div:first-child { + grid-template-columns: 1fr auto; + border-top: 0; + background: #0d1011; +} +.system-message-actions > div:first-child span { + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.8px; +} +.system-message-actions > div:first-child small { + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); +} +.system-message-actions b { + color: #5f6769; + font-size: var(--shd-type-telemetry); +} +.system-message-actions input { + height: 30px; + margin: 0; + font-size: var(--shd-type-telemetry); +} +.system-message-table .dashboard-row { + grid-template-columns: 1.3fr 0.7fr 0.7fr 0.7fr 64px; +} +.system-message-table .dashboard-row > div { + display: grid; + min-width: 0; + gap: 3px; +} +.system-message-table .dashboard-row small { + overflow: hidden; + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + text-overflow: ellipsis; + white-space: nowrap; +} +.notification-actions { + flex-wrap: wrap; +} +.notification-detail-actions { + flex-wrap: wrap; +} +.notification-detail-actions > a { + min-height: 38px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 12px; +} +@media (max-width: 900px) { + .system-message-editor-grid { + grid-template-columns: 1fr 1fr; + } + .system-message-editor-grid .full { + grid-column: 1/-1; + } + .system-message-table .dashboard-row { + grid-template-columns: 1fr; + } + .system-message-table .dashboard-row.heading { + display: none; + } + .system-message-table .dashboard-row > button { + min-height: 44px; + } +} +@media (max-width: 600px) { + .system-message-editor-grid { + grid-template-columns: 1fr; + } + .system-message-editor-grid .full { + grid-column: auto; + } + .system-message-actions > div, + .system-message-actions > div:first-child { + grid-template-columns: 1fr; + } + .system-message-actions > div > b { + display: none; + } + .notification-detail-actions > a { + width: 100%; + } +} /* Global action feedback */ -.toast-viewport{position:fixed;z-index:1000;top:calc(var(--shd-header-height) + 12px);right:clamp(12px,2vw,28px);width:min(390px,calc(100vw - 24px));display:grid;gap:8px;pointer-events:none}.toast-message{--toast-tone:#829093;position:relative;min-height:72px;display:grid;grid-template-columns:4px minmax(0,1fr) 34px;overflow:hidden;border:1px solid #414849;border-top:2px solid var(--toast-tone);background:linear-gradient(105deg,#171b1cee,#0c0f10f7);box-shadow:0 12px 30px #000b;color:var(--shd-text);pointer-events:auto;animation:toast-enter .18s ease-out}.toast-message.success{--toast-tone:var(--shd-success)}.toast-message.error{--toast-tone:var(--shd-danger)}.toast-message.warning{--toast-tone:var(--shd-orange)}.toast-message.info{--toast-tone:#6d9daf}.toast-message>i{width:4px;height:100%;background:var(--toast-tone);box-shadow:0 0 12px color-mix(in srgb,var(--toast-tone) 50%,transparent)}.toast-message>div{min-width:0;display:grid;align-content:center;gap:4px;padding:11px 10px}.toast-message span{color:var(--toast-tone);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1px}.toast-message strong{overflow-wrap:anywhere;font-size:var(--shd-type-telemetry);line-height:1.3;letter-spacing:.3px}.toast-message p{margin:0;color:#91999b;font-size:var(--shd-type-telemetry);line-height:1.45}.toast-message>button{width:34px;height:34px;margin:4px 3px 0 0;border:0;background:transparent;color:#70787a;font-size:17px;cursor:pointer}.toast-message>button:hover,.toast-message>button:focus-visible{background:#ffffff09;color:var(--toast-tone)}.toast-message>button:active{transform:translateY(1px);filter:brightness(.8)}@keyframes toast-enter{from{opacity:0;transform:translateX(18px)}to{opacity:1;transform:none}}@media(max-width:700px){.toast-viewport{top:calc(var(--shd-header-height) + 8px);right:8px;width:calc(100vw - 16px)}.toast-message{min-height:68px}}@media(prefers-reduced-motion:reduce){.toast-message{animation:none}} -.community-metrics{gap:38px}.community-metrics strong{color:var(--shd-orange);font-size:15px;letter-spacing:1px}.community-preview .gear-node{width:98px;color:var(--shd-text)}.community-preview .gear-node:hover,.community-preview .gear-node:focus-visible{border-color:var(--shd-orange);background:#211a0e}.community-preview .gear-node:active{transform:translateY(1px);filter:brightness(.86)}.community-preview .gear-node b{font-size:var(--shd-type-telemetry);letter-spacing:.55px}.community-channels{padding:58px max(24px,5vw) 70px;border-top:1px solid var(--shd-border);background:#0c0f10}.community-channels>.section-heading{max-width:1240px}.community-channels>.section-heading>p{max-width:310px;margin:0;color:var(--shd-text-muted);font:12px/1.5 var(--shd-font-editorial);text-align:right}.community-channel-grid{max-width:1240px;display:grid;grid-template-columns:repeat(4,1fr);margin:auto;border:1px solid var(--shd-border)}.community-channel-grid>a{min-height:205px;display:flex;flex-direction:column;padding:20px;border-left:1px solid var(--shd-border);background:#111415;color:var(--shd-text)}.community-channel-grid>a:first-child{border-left:0}.community-channel-grid>a:hover,.community-channel-grid>a:focus-visible{background:#171b1c;box-shadow:inset 0 2px var(--shd-orange)}.community-channel-grid>a:active{filter:brightness(.84)}.community-channel-grid small{color:#626a6c;font-size:var(--shd-type-telemetry);letter-spacing:1px}.community-channel-grid strong{margin:18px 0 9px;font-size:14px;letter-spacing:.6px}.community-channel-grid span{color:#858d8f;font:var(--shd-type-supporting)/1.55 var(--shd-font-editorial)}.community-channel-grid b{margin-top:auto;padding-top:17px;color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.community-index-page{padding-top:34px}.community-index-hero{margin-bottom:12px}.community-database-intro{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:12px}.community-database-intro>div{display:grid;grid-template-columns:150px 1fr 1.4fr;align-items:center;gap:12px;min-height:65px;padding:10px 13px;border:1px solid var(--shd-border);background:#101314}.community-database-intro span{color:#666e70;font-size:var(--shd-type-telemetry);letter-spacing:1px}.community-database-intro strong{color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.7px}.community-database-intro small{color:#7f8789;font:var(--shd-type-telemetry)/1.45 var(--shd-font-editorial)}.database-registry-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:9px}.database-registry-grid article{min-height:180px;display:flex;flex-direction:column;padding:17px;border:1px solid var(--shd-border);border-top:2px solid #43494a;background:#111415}.database-registry-grid article:hover{border-top-color:var(--shd-orange);background:#15191a}.database-registry-grid header{height:auto;display:flex;justify-content:space-between}.database-registry-grid header small{color:#596164;font-size:var(--shd-type-telemetry);letter-spacing:.8px}.database-registry-grid header b{color:var(--shd-orange);font-size:20px}.database-registry-grid h2{margin:18px 0 8px;font-size:16px}.database-registry-grid p{color:#858d8f;font:var(--shd-type-supporting)/1.55 var(--shd-font-editorial)}.database-registry-grid>article>span{margin-top:auto;color:#687073;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.news-channel-grid{display:grid;grid-template-columns:minmax(0,1fr) 330px;gap:10px}.news-primary{min-height:390px;padding:30px;border:1px solid var(--shd-border);border-top:2px solid var(--shd-orange);background:linear-gradient(135deg,#151819,#0d1011)}.news-primary>div:first-child{display:flex;justify-content:space-between;padding-bottom:13px;margin-bottom:30px;border-bottom:1px solid var(--shd-border);color:#626a6c;font-size:var(--shd-type-telemetry);letter-spacing:1px}.news-primary>div:first-child b{color:var(--shd-orange)}.news-primary h2{max-width:720px;margin:19px 0;font-size:clamp(30px,4vw,52px);line-height:.95}.news-primary>p:not(.eyebrow){max-width:760px;color:#969d9f;font:14px/1.7 var(--shd-font-editorial)}.news-primary footer{display:flex;gap:7px;margin-top:35px}.news-primary footer span{display:grid;padding:8px 10px;border:1px solid var(--shd-border);color:#626a6c;font-size:var(--shd-type-telemetry);letter-spacing:.8px}.news-primary footer b{margin-top:4px;color:#d5d8d5;font-size:var(--shd-type-telemetry)}.news-channel-grid>aside{padding:19px;border:1px solid var(--shd-border);background:#111415}.news-channel-grid>aside>div{min-height:58px;display:grid;grid-template-columns:30px 1fr auto;align-items:center;border-top:1px solid var(--shd-border)}.news-channel-grid>aside small{color:#596164;font-size:var(--shd-type-telemetry)}.news-channel-grid>aside span{font-size:var(--shd-type-telemetry);letter-spacing:.7px}.news-channel-grid>aside b{color:#6e7678;font-size:var(--shd-type-telemetry)}@media(max-width:1100px){.community-channel-grid{grid-template-columns:1fr 1fr}.community-channel-grid>a:nth-child(3){border-left:0;border-top:1px solid var(--shd-border)}.community-channel-grid>a:nth-child(4){border-top:1px solid var(--shd-border)}.community-database-intro{grid-template-columns:1fr}.database-registry-grid{grid-template-columns:1fr 1fr}}@media(max-width:750px){.community-channels>.section-heading>p{display:none}.community-channel-grid,.database-registry-grid,.news-channel-grid{grid-template-columns:1fr}.community-channel-grid>a{min-height:155px;border-left:0;border-top:1px solid var(--shd-border)}.community-channel-grid>a:first-child{border-top:0}.community-database-intro>div{grid-template-columns:1fr}.news-primary{padding:22px}.news-primary footer{flex-wrap:wrap}} +.toast-viewport { + position: fixed; + z-index: 1000; + top: calc(var(--shd-header-height) + 12px); + right: clamp(12px, 2vw, 28px); + width: min(390px, calc(100vw - 24px)); + display: grid; + gap: 8px; + pointer-events: none; +} +.toast-message { + --toast-tone: #829093; + position: relative; + min-height: 72px; + display: grid; + grid-template-columns: 4px minmax(0, 1fr) 34px; + overflow: hidden; + border: 1px solid #414849; + border-top: 2px solid var(--toast-tone); + background: linear-gradient(105deg, #171b1cee, #0c0f10f7); + box-shadow: 0 12px 30px #000b; + color: var(--shd-text); + pointer-events: auto; + animation: toast-enter 0.18s ease-out; +} +.toast-message.success { + --toast-tone: var(--shd-success); +} +.toast-message.error { + --toast-tone: var(--shd-danger); +} +.toast-message.warning { + --toast-tone: var(--shd-orange); +} +.toast-message.info { + --toast-tone: #6d9daf; +} +.toast-message > i { + width: 4px; + height: 100%; + background: var(--toast-tone); + box-shadow: 0 0 12px color-mix(in srgb, var(--toast-tone) 50%, transparent); +} +.toast-message > div { + min-width: 0; + display: grid; + align-content: center; + gap: 4px; + padding: 11px 10px; +} +.toast-message span { + color: var(--toast-tone); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 1px; +} +.toast-message strong { + overflow-wrap: anywhere; + font-size: var(--shd-type-telemetry); + line-height: 1.3; + letter-spacing: 0.3px; +} +.toast-message p { + margin: 0; + color: #91999b; + font-size: var(--shd-type-telemetry); + line-height: 1.45; +} +.toast-message > button { + width: 34px; + height: 34px; + margin: 4px 3px 0 0; + border: 0; + background: transparent; + color: #70787a; + font-size: 17px; + cursor: pointer; +} +.toast-message > button:hover, +.toast-message > button:focus-visible { + background: #ffffff09; + color: var(--toast-tone); +} +.toast-message > button:active { + transform: translateY(1px); + filter: brightness(0.8); +} +@keyframes toast-enter { + from { + opacity: 0; + transform: translateX(18px); + } + to { + opacity: 1; + transform: none; + } +} +@media (max-width: 700px) { + .toast-viewport { + top: calc(var(--shd-header-height) + 8px); + right: 8px; + width: calc(100vw - 16px); + } + .toast-message { + min-height: 68px; + } +} +@media (prefers-reduced-motion: reduce) { + .toast-message { + animation: none; + } +} +.community-metrics { + gap: 38px; +} +.community-metrics strong { + color: var(--shd-orange); + font-size: 15px; + letter-spacing: 1px; +} +.community-preview .gear-node { + width: 98px; + color: var(--shd-text); +} +.community-preview .gear-node:hover, +.community-preview .gear-node:focus-visible { + border-color: var(--shd-orange); + background: #211a0e; +} +.community-preview .gear-node:active { + transform: translateY(1px); + filter: brightness(0.86); +} +.community-preview .gear-node b { + font-size: var(--shd-type-telemetry); + letter-spacing: 0.55px; +} +.community-channels { + padding: 58px max(24px, 5vw) 70px; + border-top: 1px solid var(--shd-border); + background: #0c0f10; +} +.community-channels > .section-heading { + max-width: 1240px; +} +.community-channels > .section-heading > p { + max-width: 310px; + margin: 0; + color: var(--shd-text-muted); + font: 12px/1.5 var(--shd-font-editorial); + text-align: right; +} +.community-channel-grid { + max-width: 1240px; + display: grid; + grid-template-columns: repeat(4, 1fr); + margin: auto; + border: 1px solid var(--shd-border); +} +.community-channel-grid > a { + min-height: 205px; + display: flex; + flex-direction: column; + padding: 20px; + border-left: 1px solid var(--shd-border); + background: #111415; + color: var(--shd-text); +} +.community-channel-grid > a:first-child { + border-left: 0; +} +.community-channel-grid > a:hover, +.community-channel-grid > a:focus-visible { + background: #171b1c; + box-shadow: inset 0 2px var(--shd-orange); +} +.community-channel-grid > a:active { + filter: brightness(0.84); +} +.community-channel-grid small { + color: #626a6c; + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; +} +.community-channel-grid strong { + margin: 18px 0 9px; + font-size: 14px; + letter-spacing: 0.6px; +} +.community-channel-grid span { + color: #858d8f; + font: var(--shd-type-supporting)/1.55 var(--shd-font-editorial); +} +.community-channel-grid b { + margin-top: auto; + padding-top: 17px; + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); + letter-spacing: 0.8px; +} +.community-index-page { + padding-top: 34px; +} +.community-index-hero { + margin-bottom: 12px; +} +.community-database-intro { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin-bottom: 12px; +} +.community-database-intro > div { + display: grid; + grid-template-columns: 150px 1fr 1.4fr; + align-items: center; + gap: 12px; + min-height: 65px; + padding: 10px 13px; + border: 1px solid var(--shd-border); + background: #101314; +} +.community-database-intro span { + color: #666e70; + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; +} +.community-database-intro strong { + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); + letter-spacing: 0.7px; +} +.community-database-intro small { + color: #7f8789; + font: var(--shd-type-telemetry)/1.45 var(--shd-font-editorial); +} +.database-registry-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 9px; +} +.database-registry-grid article { + min-height: 180px; + display: flex; + flex-direction: column; + padding: 17px; + border: 1px solid var(--shd-border); + border-top: 2px solid #43494a; + background: #111415; +} +.database-registry-grid article:hover { + border-top-color: var(--shd-orange); + background: #15191a; +} +.database-registry-grid header { + height: auto; + display: flex; + justify-content: space-between; +} +.database-registry-grid header small { + color: #596164; + font-size: var(--shd-type-telemetry); + letter-spacing: 0.8px; +} +.database-registry-grid header b { + color: var(--shd-orange); + font-size: 20px; +} +.database-registry-grid h2 { + margin: 18px 0 8px; + font-size: 16px; +} +.database-registry-grid p { + color: #858d8f; + font: var(--shd-type-supporting)/1.55 var(--shd-font-editorial); +} +.database-registry-grid > article > span { + margin-top: auto; + color: #687073; + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.8px; +} +.news-channel-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) 330px; + gap: 10px; +} +.news-primary { + min-height: 390px; + padding: 30px; + border: 1px solid var(--shd-border); + border-top: 2px solid var(--shd-orange); + background: linear-gradient(135deg, #151819, #0d1011); +} +.news-primary > div:first-child { + display: flex; + justify-content: space-between; + padding-bottom: 13px; + margin-bottom: 30px; + border-bottom: 1px solid var(--shd-border); + color: #626a6c; + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; +} +.news-primary > div:first-child b { + color: var(--shd-orange); +} +.news-primary h2 { + max-width: 720px; + margin: 19px 0; + font-size: clamp(30px, 4vw, 52px); + line-height: 0.95; +} +.news-primary > p:not(.eyebrow) { + max-width: 760px; + color: #969d9f; + font: 14px/1.7 var(--shd-font-editorial); +} +.news-primary footer { + display: flex; + gap: 7px; + margin-top: 35px; +} +.news-primary footer span { + display: grid; + padding: 8px 10px; + border: 1px solid var(--shd-border); + color: #626a6c; + font-size: var(--shd-type-telemetry); + letter-spacing: 0.8px; +} +.news-primary footer b { + margin-top: 4px; + color: #d5d8d5; + font-size: var(--shd-type-telemetry); +} +.news-channel-grid > aside { + padding: 19px; + border: 1px solid var(--shd-border); + background: #111415; +} +.news-channel-grid > aside > div { + min-height: 58px; + display: grid; + grid-template-columns: 30px 1fr auto; + align-items: center; + border-top: 1px solid var(--shd-border); +} +.news-channel-grid > aside small { + color: #596164; + font-size: var(--shd-type-telemetry); +} +.news-channel-grid > aside span { + font-size: var(--shd-type-telemetry); + letter-spacing: 0.7px; +} +.news-channel-grid > aside b { + color: #6e7678; + font-size: var(--shd-type-telemetry); +} +@media (max-width: 1100px) { + .community-channel-grid { + grid-template-columns: 1fr 1fr; + } + .community-channel-grid > a:nth-child(3) { + border-left: 0; + border-top: 1px solid var(--shd-border); + } + .community-channel-grid > a:nth-child(4) { + border-top: 1px solid var(--shd-border); + } + .community-database-intro { + grid-template-columns: 1fr; + } + .database-registry-grid { + grid-template-columns: 1fr 1fr; + } +} +@media (max-width: 750px) { + .community-channels > .section-heading > p { + display: none; + } + .community-channel-grid, + .database-registry-grid, + .news-channel-grid { + grid-template-columns: 1fr; + } + .community-channel-grid > a { + min-height: 155px; + border-left: 0; + border-top: 1px solid var(--shd-border); + } + .community-channel-grid > a:first-child { + border-top: 0; + } + .community-database-intro > div { + grid-template-columns: 1fr; + } + .news-primary { + padding: 22px; + } + .news-primary footer { + flex-wrap: wrap; + } +} /* Readable operational type floors */ .eyebrow, -.community-index-hero>div:last-child span, +.community-index-hero > div:last-child span, .community-database-intro span, .community-database-intro strong, .database-registry-grid header small, -.database-registry-grid>a>span{ - font-size:var(--shd-type-telemetry); - line-height:1.25; - letter-spacing:.08em; +.database-registry-grid > a > span { + font-size: var(--shd-type-telemetry); + line-height: 1.25; + letter-spacing: 0.08em; } /* Specialization combat modeling: shared authoring and builder patterns. */ -.combat-effect-definition-grid{grid-template-columns:minmax(220px,.7fr) minmax(360px,1.3fr)}.combat-effect-template textarea{min-height:86px} -.specialization-attack-profile{display:grid;gap:var(--shd-gap-sm);padding:var(--shd-gap-sm);border:1px solid var(--shd-model-border);border-left:2px solid var(--shd-info);background:var(--shd-model-surface)}.specialization-attack-profile>.segmented-choice{margin:0}.specialization-attack-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--shd-gap-sm);align-items:end}.specialization-attack-grid>.field,.specialization-attack-grid>.segmented-choice{margin:0}.calculation-note{margin:0;padding:8px;border:1px solid var(--shd-border-soft);color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.45} -.rank-combat-effects{display:grid;gap:var(--shd-gap-sm);margin-top:var(--shd-gap-md);padding-top:var(--shd-gap-md);border-top:1px solid var(--shd-border)}.rank-combat-effect{display:grid;gap:var(--shd-gap-sm);padding:var(--shd-gap-sm);border:1px solid var(--shd-model-border);border-left:2px solid var(--shd-info);background:var(--shd-model-surface)}.rank-combat-effect>header{display:flex;align-items:center;justify-content:space-between}.rank-combat-effect>header>div{display:grid;gap:2px}.rank-combat-effect>header span,.rank-combat-effect>header small{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.rank-combat-effect>header span{color:var(--shd-info)}.combat-trigger-grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:var(--shd-gap-sm)}.combat-lifecycle-facts{display:flex;flex-wrap:wrap;gap:var(--shd-gap-xs)}.combat-lifecycle-facts span{padding:5px 7px;border:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:.6px}.combat-lifecycle-facts b{color:var(--shd-text)}.combat-modifier-list{display:grid;gap:var(--shd-gap-xs)}.combat-modifier-list>div{display:grid;grid-template-columns:minmax(220px,1fr) 150px 140px auto;gap:var(--shd-gap-sm);align-items:end;padding:6px;border-top:1px solid var(--shd-border-soft)}.combat-modifier-list>div>.field{margin:0} -.builder-remediation{display:grid;gap:3px;padding:9px;border:1px solid var(--shd-danger);border-left-width:2px;background:#1d1212}.builder-remediation strong{color:var(--shd-danger);font-size:var(--shd-type-telemetry);letter-spacing:.9px}.builder-remediation span{color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.builder-specialization-identity{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:var(--shd-gap-md);align-items:center;padding:var(--shd-gap-md);border:1px solid var(--shd-model-border);border-left:2px solid var(--shd-orange);background:var(--shd-model-surface)}.builder-specialization-identity h3,.builder-specialization-identity p{margin:0}.builder-specialization-identity small,.builder-specialization-identity>b{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.builder-specialization-identity p{margin-top:4px;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.builder-specialization-identity>b{padding:5px 7px;border:1px solid var(--shd-border);color:var(--shd-text-muted)}.builder-specialization-identity>b.configured{border-color:#365d44;color:var(--shd-success)}.builder-ability-chip-panels{min-width:0;display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--shd-gap-sm)}.builder-ability-chip-panels>section{min-width:0;display:grid;grid-template-rows:auto minmax(48px,auto) auto;gap:var(--shd-gap-sm);padding:var(--shd-gap-sm);border:1px solid var(--shd-border);background:var(--shd-surface)}.builder-ability-chip-panels header{min-width:0;display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:var(--shd-gap-sm);align-items:center}.builder-ability-chip-panels header img{width:38px;height:38px}.builder-ability-chip-panels header div{min-width:0;display:grid}.builder-ability-chip-panels header strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.builder-ability-chip-panels header small,.builder-ability-chip-panels header b{font-size:var(--shd-type-telemetry);letter-spacing:.7px}.builder-ability-chip-panels header b{white-space:nowrap}.builder-ability-chip-panels>section>p{min-width:0;margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);overflow-wrap:anywhere}.builder-ability-chip-panels>section>div{min-width:0;display:grid;gap:4px}.builder-ability-chip-panels>section>div>button{min-width:0;min-height:54px;display:grid;grid-template-columns:70px minmax(0,1fr);grid-template-rows:auto auto;gap:1px 8px;padding:7px;border:1px solid var(--shd-border);background:#0d1011;color:var(--shd-text);text-align:left;cursor:pointer}.builder-ability-chip-panels>section>div>button:hover,.builder-ability-chip-panels>section>div>button:focus-visible{border-color:var(--shd-orange)}.builder-ability-chip-panels button small{grid-row:1/3;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900}.builder-ability-chip-panels button strong,.builder-ability-chip-panels button span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.builder-ability-chip-panels button span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry)}.builder-specialization-telemetry{display:flex;flex-wrap:wrap;gap:var(--shd-gap-xs)}.builder-specialization-telemetry span{padding:6px 8px;border:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.builder-specialization-telemetry b{color:var(--shd-orange)}.builder-focus-tree{display:grid;gap:var(--shd-gap-sm)}.builder-focus-tree>section{border:1px solid var(--shd-border);background:var(--shd-model-surface)}.builder-focus-tree>section>header{display:flex;justify-content:space-between;padding:6px 8px;border-bottom:1px solid var(--shd-border);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.builder-focus-tree>section>header span{color:var(--shd-orange)}.builder-focus-tree>section>div{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:var(--shd-gap-xs);padding:var(--shd-gap-xs)}.builder-focus-tree article{display:grid;grid-template-columns:auto 1fr;gap:var(--shd-gap-sm);padding:var(--shd-gap-sm);border:1px solid var(--shd-border);background:var(--shd-surface)}.builder-focus-tree article>img{width:42px;height:42px}.builder-focus-tree article>div{display:grid;gap:3px}.builder-focus-tree article small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.35}.builder-focus-tree article>.segmented-choice{grid-column:1/-1;margin:0}.segmented-choice span.unavailable{opacity:.38;cursor:not-allowed;filter:saturate(.25)}.builder-conditional-effects,.builder-combat-results{display:grid;gap:var(--shd-gap-sm)}.builder-combat-results{padding:var(--shd-gap-sm);border-bottom:1px solid var(--shd-border)}.builder-return-link{width:max-content;min-height:36px;padding:7px 9px;border:1px solid var(--shd-border);background:transparent;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px;cursor:pointer}.builder-return-link:hover,.builder-return-link:focus-visible{border-color:var(--shd-orange)} -.builder-skill-activation{display:grid;gap:var(--shd-gap-sm)}.builder-skill-activation>section{display:grid;grid-template-columns:minmax(180px,.7fr) minmax(340px,1.3fr);gap:var(--shd-gap-sm);align-items:end;padding:var(--shd-gap-sm);border:1px solid var(--shd-model-border);border-left:2px solid var(--shd-info);background:var(--shd-model-surface)}.builder-skill-activation>section>header{display:grid;grid-template-columns:auto 1fr;gap:var(--shd-gap-sm);align-items:center}.builder-skill-activation>section>header img{width:40px;height:40px}.builder-skill-activation>section>header div{display:grid;gap:2px}.builder-skill-activation>section>header small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.builder-skill-activation>section>.segmented-choice{margin:0}.builder-skill-assumptions{grid-column:1/-1;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--shd-gap-sm)} -@media(max-width:1050px){.combat-trigger-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.combat-modifier-list>div{grid-template-columns:1fr 1fr}.builder-ability-chip-panels{grid-template-columns:1fr}.builder-specialization-identity{grid-template-columns:auto 1fr}.builder-specialization-identity>b{grid-column:1/-1;width:max-content}.builder-skill-activation>section{grid-template-columns:1fr}.specialization-attack-grid{grid-template-columns:repeat(2,minmax(0,1fr))}} -@media(max-width:650px){.combat-effect-definition-grid,.combat-trigger-grid,.combat-modifier-list>div,.specialization-attack-grid{grid-template-columns:1fr}.rank-combat-effect button,.builder-return-link,.builder-ability-chip-panels button{min-height:44px}.builder-specialization-identity{grid-template-columns:1fr}.builder-specialization-identity .focus-composite-icon{justify-self:start}.builder-skill-assumptions{grid-template-columns:1fr}} -.community-index-hero>div:last-child .primary{ - font-size:var(--shd-type-telemetry); +.combat-effect-definition-grid { + grid-template-columns: minmax(220px, 0.7fr) minmax(360px, 1.3fr); +} +.combat-effect-template textarea { + min-height: 86px; +} +.specialization-attack-profile { + display: grid; + gap: var(--shd-gap-sm); + padding: var(--shd-gap-sm); + border: 1px solid var(--shd-model-border); + border-left: 2px solid var(--shd-info); + background: var(--shd-model-surface); +} +.specialization-attack-profile > .segmented-choice { + margin: 0; +} +.specialization-attack-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--shd-gap-sm); + align-items: end; +} +.specialization-attack-grid > .field, +.specialization-attack-grid > .segmented-choice { + margin: 0; +} +.calculation-note { + margin: 0; + padding: 8px; + border: 1px solid var(--shd-border-soft); + color: var(--shd-text-muted); + font-size: var(--shd-type-supporting); + line-height: 1.45; +} +.rank-combat-effects { + display: grid; + gap: var(--shd-gap-sm); + margin-top: var(--shd-gap-md); + padding-top: var(--shd-gap-md); + border-top: 1px solid var(--shd-border); +} +.rank-combat-effect { + display: grid; + gap: var(--shd-gap-sm); + padding: var(--shd-gap-sm); + border: 1px solid var(--shd-model-border); + border-left: 2px solid var(--shd-info); + background: var(--shd-model-surface); +} +.rank-combat-effect > header { + display: flex; + align-items: center; + justify-content: space-between; +} +.rank-combat-effect > header > div { + display: grid; + gap: 2px; +} +.rank-combat-effect > header span, +.rank-combat-effect > header small { + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.8px; +} +.rank-combat-effect > header span { + color: var(--shd-info); +} +.combat-trigger-grid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: var(--shd-gap-sm); +} +.combat-lifecycle-facts { + display: flex; + flex-wrap: wrap; + gap: var(--shd-gap-xs); +} +.combat-lifecycle-facts span { + padding: 5px 7px; + border: 1px solid var(--shd-border); + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + letter-spacing: 0.6px; +} +.combat-lifecycle-facts b { + color: var(--shd-text); +} +.combat-modifier-list { + display: grid; + gap: var(--shd-gap-xs); +} +.combat-modifier-list > div { + display: grid; + grid-template-columns: minmax(220px, 1fr) 150px 140px auto; + gap: var(--shd-gap-sm); + align-items: end; + padding: 6px; + border-top: 1px solid var(--shd-border-soft); +} +.combat-modifier-list > div > .field { + margin: 0; +} +.builder-remediation { + display: grid; + gap: 3px; + padding: 9px; + border: 1px solid var(--shd-danger); + border-left-width: 2px; + background: #1d1212; +} +.builder-remediation strong { + color: var(--shd-danger); + font-size: var(--shd-type-telemetry); + letter-spacing: 0.9px; +} +.builder-remediation span { + color: var(--shd-text-muted); + font-size: var(--shd-type-supporting); +} +.builder-specialization-identity { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: var(--shd-gap-md); + align-items: center; + padding: var(--shd-gap-md); + border: 1px solid var(--shd-model-border); + border-left: 2px solid var(--shd-orange); + background: var(--shd-model-surface); +} +.builder-specialization-identity h3, +.builder-specialization-identity p { + margin: 0; +} +.builder-specialization-identity small, +.builder-specialization-identity > b { + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.8px; +} +.builder-specialization-identity p { + margin-top: 4px; + color: var(--shd-text-muted); + font-size: var(--shd-type-supporting); +} +.builder-specialization-identity > b { + padding: 5px 7px; + border: 1px solid var(--shd-border); + color: var(--shd-text-muted); +} +.builder-specialization-identity > b.configured { + border-color: #365d44; + color: var(--shd-success); +} +.builder-ability-chip-panels { + min-width: 0; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--shd-gap-sm); +} +.builder-ability-chip-panels > section { + min-width: 0; + display: grid; + grid-template-rows: auto minmax(48px, auto) auto; + gap: var(--shd-gap-sm); + padding: var(--shd-gap-sm); + border: 1px solid var(--shd-border); + background: var(--shd-surface); +} +.builder-ability-chip-panels header { + min-width: 0; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: var(--shd-gap-sm); + align-items: center; +} +.builder-ability-chip-panels header img { + width: 38px; + height: 38px; +} +.builder-ability-chip-panels header div { + min-width: 0; + display: grid; +} +.builder-ability-chip-panels header strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.builder-ability-chip-panels header small, +.builder-ability-chip-panels header b { + font-size: var(--shd-type-telemetry); + letter-spacing: 0.7px; +} +.builder-ability-chip-panels header b { + white-space: nowrap; +} +.builder-ability-chip-panels > section > p { + min-width: 0; + margin: 0; + color: var(--shd-text-muted); + font-size: var(--shd-type-supporting); + overflow-wrap: anywhere; +} +.builder-ability-chip-panels > section > div { + min-width: 0; + display: grid; + gap: 4px; +} +.builder-ability-chip-panels > section > div > button { + min-width: 0; + min-height: 54px; + display: grid; + grid-template-columns: 70px minmax(0, 1fr); + grid-template-rows: auto auto; + gap: 1px 8px; + padding: 7px; + border: 1px solid var(--shd-border); + background: #0d1011; + color: var(--shd-text); + text-align: left; + cursor: pointer; +} +.builder-ability-chip-panels > section > div > button:hover, +.builder-ability-chip-panels > section > div > button:focus-visible { + border-color: var(--shd-orange); +} +.builder-ability-chip-panels button small { + grid-row: 1/3; + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); + font-weight: 900; +} +.builder-ability-chip-panels button strong, +.builder-ability-chip-panels button span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.builder-ability-chip-panels button span { + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); +} +.builder-specialization-telemetry { + display: flex; + flex-wrap: wrap; + gap: var(--shd-gap-xs); +} +.builder-specialization-telemetry span { + padding: 6px 8px; + border: 1px solid var(--shd-border); + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.7px; +} +.builder-specialization-telemetry b { + color: var(--shd-orange); +} +.builder-focus-tree { + display: grid; + gap: var(--shd-gap-sm); +} +.builder-focus-tree > section { + border: 1px solid var(--shd-border); + background: var(--shd-model-surface); +} +.builder-focus-tree > section > header { + display: flex; + justify-content: space-between; + padding: 6px 8px; + border-bottom: 1px solid var(--shd-border); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.8px; +} +.builder-focus-tree > section > header span { + color: var(--shd-orange); +} +.builder-focus-tree > section > div { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: var(--shd-gap-xs); + padding: var(--shd-gap-xs); +} +.builder-focus-tree article { + display: grid; + grid-template-columns: auto 1fr; + gap: var(--shd-gap-sm); + padding: var(--shd-gap-sm); + border: 1px solid var(--shd-border); + background: var(--shd-surface); +} +.builder-focus-tree article > img { + width: 42px; + height: 42px; +} +.builder-focus-tree article > div { + display: grid; + gap: 3px; +} +.builder-focus-tree article small { + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + line-height: 1.35; +} +.builder-focus-tree article > .segmented-choice { + grid-column: 1/-1; + margin: 0; +} +.segmented-choice span.unavailable { + opacity: 0.38; + cursor: not-allowed; + filter: saturate(0.25); +} +.builder-conditional-effects, +.builder-combat-results { + display: grid; + gap: var(--shd-gap-sm); +} +.builder-combat-results { + padding: var(--shd-gap-sm); + border-bottom: 1px solid var(--shd-border); +} +.builder-return-link { + width: max-content; + min-height: 36px; + padding: 7px 9px; + border: 1px solid var(--shd-border); + background: transparent; + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.8px; + cursor: pointer; +} +.builder-return-link:hover, +.builder-return-link:focus-visible { + border-color: var(--shd-orange); +} +.builder-skill-activation { + display: grid; + gap: var(--shd-gap-sm); +} +.builder-skill-activation > section { + display: grid; + grid-template-columns: minmax(180px, 0.7fr) minmax(340px, 1.3fr); + gap: var(--shd-gap-sm); + align-items: end; + padding: var(--shd-gap-sm); + border: 1px solid var(--shd-model-border); + border-left: 2px solid var(--shd-info); + background: var(--shd-model-surface); +} +.builder-skill-activation > section > header { + display: grid; + grid-template-columns: auto 1fr; + gap: var(--shd-gap-sm); + align-items: center; +} +.builder-skill-activation > section > header img { + width: 40px; + height: 40px; +} +.builder-skill-activation > section > header div { + display: grid; + gap: 2px; +} +.builder-skill-activation > section > header small { + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.7px; +} +.builder-skill-activation > section > .segmented-choice { + margin: 0; +} +.builder-skill-assumptions { + grid-column: 1/-1; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--shd-gap-sm); +} +@media (max-width: 1050px) { + .combat-trigger-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .combat-modifier-list > div { + grid-template-columns: 1fr 1fr; + } + .builder-ability-chip-panels { + grid-template-columns: 1fr; + } + .builder-specialization-identity { + grid-template-columns: auto 1fr; + } + .builder-specialization-identity > b { + grid-column: 1/-1; + width: max-content; + } + .builder-skill-activation > section { + grid-template-columns: 1fr; + } + .specialization-attack-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} +@media (max-width: 650px) { + .combat-effect-definition-grid, + .combat-trigger-grid, + .combat-modifier-list > div, + .specialization-attack-grid { + grid-template-columns: 1fr; + } + .rank-combat-effect button, + .builder-return-link, + .builder-ability-chip-panels button { + min-height: 44px; + } + .builder-specialization-identity { + grid-template-columns: 1fr; + } + .builder-specialization-identity .focus-composite-icon { + justify-self: start; + } + .builder-skill-assumptions { + grid-template-columns: 1fr; + } +} +.community-index-hero > div:last-child .primary { + font-size: var(--shd-type-telemetry); } .community-database-intro small, -.database-registry-grid p{ - font-size:var(--shd-type-supporting); - line-height:1.55; - letter-spacing:normal; +.database-registry-grid p { + font-size: var(--shd-type-supporting); + line-height: 1.55; + letter-spacing: normal; } -.database-registry-grid p{ - max-width:65ch; +.database-registry-grid p { + max-width: 65ch; } - .site-header .desktop-agent-trigger>span small, -.site-header .desktop-agent-trigger>span b, +.site-header .desktop-agent-trigger > span small, +.site-header .desktop-agent-trigger > span b, .footer-discord span, .footer-discord small, .footer-nav a, -.site-footer>div>p, -.fan-disclaimer{ - font-size:var(--shd-type-telemetry); - line-height:1.35; +.site-footer > div > p, +.fan-disclaimer { + font-size: var(--shd-type-telemetry); + line-height: 1.35; } -.site-footer>div>p:not(.fan-disclaimer){ - font-size:var(--shd-type-supporting); - line-height:1.5; +.site-footer > div > p:not(.fan-disclaimer) { + font-size: var(--shd-type-supporting); + line-height: 1.5; } -.fan-disclaimer{ - letter-spacing:.04em; +.fan-disclaimer { + letter-spacing: 0.04em; } /* Keep the user registry action in its dedicated final table column. */ -.user-row{ - grid-template-columns:minmax(0,1.1fr) minmax(0,.65fr) minmax(0,1.1fr) minmax(0,1fr) minmax(0,1fr) minmax(0,.9fr) minmax(48px,.3fr); +.user-row { + grid-template-columns: + minmax(0, 1.1fr) minmax(0, 0.65fr) minmax(0, 1.1fr) + minmax(0, 1fr) minmax(0, 1fr) minmax(0, 0.9fr) minmax(48px, 0.3fr); } -.user-row>.row-action{ - grid-column:7; - grid-row:1; - white-space:nowrap; +.user-row > .row-action { + grid-column: 7; + grid-row: 1; + white-space: nowrap; } /* ISAC progressive disclosure and compact persistent status. */ -.compact-status{ - display:flex; - align-items:center; - gap:0; - border:1px solid var(--shd-border); - background:var(--shd-surface); +.compact-status { + display: flex; + align-items: center; + gap: 0; + border: 1px solid var(--shd-border); + background: var(--shd-surface); } -.compact-status>span{ - position:relative; - min-width:105px; - min-height:48px; - display:grid; - align-content:center; - gap:3px; - padding:7px 10px; - border-left:1px solid var(--shd-border); - color:var(--shd-text); - font-size:var(--shd-type-telemetry); - font-weight:900; - letter-spacing:.8px; - white-space:nowrap; +.compact-status > span { + position: relative; + min-width: 105px; + min-height: 48px; + display: grid; + align-content: center; + gap: 3px; + padding: 7px 10px; + border-left: 1px solid var(--shd-border); + color: var(--shd-text); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.8px; + white-space: nowrap; } -.compact-status>span:first-child{border-left:0} -.compact-status small{ - color:var(--shd-text-dim); - font-size:inherit; - font-weight:900; - letter-spacing:1.1px; - line-height:1; +.compact-status > span:first-child { + border-left: 0; } -.compact-status strong{color:var(--shd-text);font-size:inherit;letter-spacing:inherit;line-height:1.05} -.compact-status i{ - position:absolute; - top:8px; - right:8px; - width:6px; - height:6px; - border-radius:50%; - background:var(--shd-success); - box-shadow:0 0 8px color-mix(in srgb,var(--shd-success) 65%,transparent); +.compact-status small { + color: var(--shd-text-dim); + font-size: inherit; + font-weight: 900; + letter-spacing: 1.1px; + line-height: 1; } -.compact-status .data-mode-status{border-left:1px solid var(--shd-border)} -.admin-sidebar{overflow-y:auto} -.sidebar-module-search{ - display:grid; - gap:5px; - padding:10px; - border-bottom:1px solid var(--shd-border); - background:var(--shd-terminal); +.compact-status strong { + color: var(--shd-text); + font-size: inherit; + letter-spacing: inherit; + line-height: 1.05; } -.sidebar-module-search span{ - color:var(--shd-text-dim); - font-size:var(--shd-type-telemetry); - font-weight:900; - letter-spacing:1px; +.compact-status i { + position: absolute; + top: 8px; + right: 8px; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--shd-success); + box-shadow: 0 0 8px color-mix(in srgb, var(--shd-success) 65%, transparent); } -.sidebar-module-search input{ - width:100%; - height:34px; - margin:0; - padding:0 9px; - font-size:var(--shd-type-supporting); +.compact-status .data-mode-status { + border-left: 1px solid var(--shd-border); } -.admin-sidebar button.sidebar-label{ - width:100%; - flex:0 0 auto; - border:0; - border-bottom:1px solid var(--shd-border); - background:var(--shd-surface); - color:inherit; - cursor:pointer; - text-align:left; +.admin-sidebar { + overflow-y: auto; } -.admin-sidebar button.sidebar-label:hover{background:var(--shd-surface-active)} -.admin-sidebar button.sidebar-label:focus-visible{ - position:relative; - z-index:1; - outline:2px solid var(--shd-orange); - outline-offset:-2px; +.sidebar-module-search { + display: grid; + gap: 5px; + padding: 10px; + border-bottom: 1px solid var(--shd-border); + background: var(--shd-terminal); } -.sidebar-label b{display:flex;align-items:center;gap:7px} -.sidebar-label b i{ - min-width:12px; - color:var(--shd-orange); - font-size:14px; - font-style:normal; - text-align:center; +.sidebar-module-search span { + color: var(--shd-text-dim); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 1px; } -.registry-pagination:not(:has(.pagination-pages)){ - grid-template-columns:minmax(190px,260px) 1fr auto; +.sidebar-module-search input { + width: 100%; + height: 34px; + margin: 0; + padding: 0 9px; + font-size: var(--shd-type-supporting); } -.registry-pagination:not(:has(.pagination-pages)):not(:has(.pagination-search)){ - grid-template-columns:1fr auto; +.admin-sidebar button.sidebar-label { + width: 100%; + flex: 0 0 auto; + border: 0; + border-bottom: 1px solid var(--shd-border); + background: var(--shd-surface); + color: inherit; + cursor: pointer; + text-align: left; } -.registry-pagination:not(:has(.pagination-pages)) .pagination-range{justify-content:flex-end} -@media(max-width:900px){ - .compact-status{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));width:100%} - .compact-status>span:nth-child(3){border-left:0;border-top:1px solid var(--shd-border)} - .compact-status>span:nth-child(n+4){border-top:1px solid var(--shd-border)} - .compact-status .data-mode-status{grid-column:1/-1} +.admin-sidebar button.sidebar-label:hover { + background: var(--shd-surface-active); } -@media(max-width:650px){ - .registry-pagination:not(:has(.pagination-pages)){grid-template-columns:1fr} - .registry-pagination:not(:has(.pagination-pages)) .pagination-range{justify-content:center} +.admin-sidebar button.sidebar-label:focus-visible { + position: relative; + z-index: 1; + outline: 2px solid var(--shd-orange); + outline-offset: -2px; +} +.sidebar-label b { + display: flex; + align-items: center; + gap: 7px; +} +.sidebar-label b i { + min-width: 12px; + color: var(--shd-orange); + font-size: 14px; + font-style: normal; + text-align: center; +} +.registry-pagination:not(:has(.pagination-pages)) { + grid-template-columns: minmax(190px, 260px) 1fr auto; +} +.registry-pagination:not(:has(.pagination-pages)):not( + :has(.pagination-search) + ) { + grid-template-columns: 1fr auto; +} +.registry-pagination:not(:has(.pagination-pages)) .pagination-range { + justify-content: flex-end; +} +@media (max-width: 900px) { + .compact-status { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + width: 100%; + } + .compact-status > span:nth-child(3) { + border-left: 0; + border-top: 1px solid var(--shd-border); + } + .compact-status > span:nth-child(n + 4) { + border-top: 1px solid var(--shd-border); + } + .compact-status .data-mode-status { + grid-column: 1/-1; + } +} +@media (max-width: 650px) { + .registry-pagination:not(:has(.pagination-pages)) { + grid-template-columns: 1fr; + } + .registry-pagination:not(:has(.pagination-pages)) .pagination-range { + justify-content: center; + } } /* Registry columns sort the full filtered result set before pagination. */ -.table-heading .sortable-column{ - min-width:0; - display:flex; - align-items:center; - justify-content:flex-start; - gap:6px; - padding:5px 0; - color:inherit; - font:inherit; - letter-spacing:inherit; - text-decoration:none; - cursor:pointer; +.table-heading .sortable-column { + min-width: 0; + display: flex; + align-items: center; + justify-content: flex-start; + gap: 6px; + padding: 5px 0; + color: inherit; + font: inherit; + letter-spacing: inherit; + text-decoration: none; + cursor: pointer; } .table-heading .sortable-column:hover, -.table-heading .sortable-column:focus-visible{color:var(--shd-orange)} -.table-heading .sortable-column:focus-visible{ - outline:2px solid var(--shd-orange); - outline-offset:2px; +.table-heading .sortable-column:focus-visible { + color: var(--shd-orange); } -.table-heading .sortable-column:active{transform:translateY(1px)} -.table-heading .sortable-column.active{color:var(--shd-text)} -.sort-direction{ - flex:0 0 auto; - color:var(--shd-text-dim); - font-size:var(--shd-type-telemetry); +.table-heading .sortable-column:focus-visible { + outline: 2px solid var(--shd-orange); + outline-offset: 2px; } -.sortable-column.active .sort-direction{color:var(--shd-orange)} -@media(prefers-reduced-motion:reduce){ - .table-heading .sortable-column:active{transform:none} +.table-heading .sortable-column:active { + transform: translateY(1px); +} +.table-heading .sortable-column.active { + color: var(--shd-text); +} +.sort-direction { + flex: 0 0 auto; + color: var(--shd-text-dim); + font-size: var(--shd-type-telemetry); +} +.sortable-column.active .sort-direction { + color: var(--shd-orange); +} +@media (prefers-reduced-motion: reduce) { + .table-heading .sortable-column:active { + transform: none; + } } /* Separate ISAC operational domains from their module destinations. */ -.admin-sidebar button.sidebar-label{ - position:relative; - min-height:39px; - height:39px; - padding:8px 10px; - border-bottom-color:#343a3b; - background:#171b1c; +.admin-sidebar button.sidebar-label { + position: relative; + min-height: 39px; + height: 39px; + padding: 8px 10px; + border-bottom-color: #343a3b; + background: #171b1c; } -.admin-sidebar button.sidebar-label::before{ - content:""; - flex:0 0 3px; - width:3px; - height:15px; - margin-right:8px; - background:#596164; +.admin-sidebar button.sidebar-label::before { + content: ''; + flex: 0 0 3px; + width: 3px; + height: 15px; + margin-right: 8px; + background: #596164; } -.admin-sidebar button.sidebar-label[aria-expanded="true"]::before{background:var(--shd-orange)} -.admin-sidebar button.sidebar-label:hover{background:#1c2021} -.admin-sidebar button.sidebar-label span{ - margin-right:auto; - color:#c3c8c6; - font-weight:900; +.admin-sidebar button.sidebar-label[aria-expanded='true']::before { + background: var(--shd-orange); } -.admin-sidebar button.sidebar-label b{color:#737b7d} -.admin-sidebar nav{background:#0b0e0f} -.admin-sidebar nav a{ - min-height:47px; - background:#0d1011; - color:#7d8587; - font-weight:600; +.admin-sidebar button.sidebar-label:hover { + background: #1c2021; +} +.admin-sidebar button.sidebar-label span { + margin-right: auto; + color: #c3c8c6; + font-weight: 900; +} +.admin-sidebar button.sidebar-label b { + color: #737b7d; +} +.admin-sidebar nav { + background: #0b0e0f; +} +.admin-sidebar nav a { + min-height: 47px; + background: #0d1011; + color: #7d8587; + font-weight: 600; +} +.admin-sidebar nav a span { + padding-left: 20px; +} +.admin-sidebar nav a.active { + box-shadow: inset 3px 0 var(--shd-orange); +} +.metrics-panel header b.metric-ok, +.metric-ok { + color: var(--shd-success); +} +.metrics-panel header b.metric-warning, +.metric-warning { + color: var(--shd-orange); +} +.metrics-panel header b.metric-critical, +.metric-critical { + color: var(--shd-danger); } -.admin-sidebar nav a span{padding-left:20px} -.admin-sidebar nav a.active{box-shadow:inset 3px 0 var(--shd-orange)} -.metrics-panel header b.metric-ok,.metric-ok{color:var(--shd-success)} -.metrics-panel header b.metric-warning,.metric-warning{color:var(--shd-orange)} -.metrics-panel header b.metric-critical,.metric-critical{color:var(--shd-danger)} /* Prelaunch shell: an anonymous deployment surface with authenticated entry. */ -.launch-page{ - position:relative; - isolation:isolate; - min-height:100vh; - display:flex; - flex-direction:column; - overflow:hidden; - padding:0 clamp(20px,4vw,72px); - background: - radial-gradient(circle at 72% 35%,rgba(246,168,33,.09),transparent 27%), - linear-gradient(145deg,#111617 0,#090b0c 48%,#0d1011 100%); +.launch-page { + position: relative; + isolation: isolate; + min-height: 100vh; + display: flex; + flex-direction: column; + overflow: hidden; + padding: 0 clamp(20px, 4vw, 72px); + background: + radial-gradient( + circle at 72% 35%, + rgba(246, 168, 33, 0.09), + transparent 27% + ), + linear-gradient(145deg, #111617 0, #090b0c 48%, #0d1011 100%); } -.launch-grid{ - position:absolute; - inset:0; - z-index:-1; - opacity:.2; - background-image: - linear-gradient(rgba(100,110,112,.14) 1px,transparent 1px), - linear-gradient(90deg,rgba(100,110,112,.14) 1px,transparent 1px); - background-size:48px 48px; - mask-image:linear-gradient(to bottom,#000,transparent 88%); +.launch-grid { + position: absolute; + inset: 0; + z-index: -1; + opacity: 0.2; + background-image: + linear-gradient(rgba(100, 110, 112, 0.14) 1px, transparent 1px), + linear-gradient(90deg, rgba(100, 110, 112, 0.14) 1px, transparent 1px); + background-size: 48px 48px; + mask-image: linear-gradient(to bottom, #000, transparent 88%); } -.launch-header{ - min-height:94px; - display:flex; - align-items:center; - justify-content:space-between; - border-bottom:1px solid var(--shd-border); +.launch-header { + min-height: 94px; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--shd-border); } -.launch-header .brand{position:static} -.launch-signal{ - display:grid; - grid-template-columns:8px auto; - column-gap:9px; - align-items:center; - padding:10px 13px; - border:1px solid var(--shd-border); - background:rgba(13,16,17,.8); +.launch-header .brand { + position: static; } -.launch-signal i{ - grid-row:1/3; - width:7px; - height:7px; - border-radius:50%; - background:var(--shd-orange); - box-shadow:0 0 10px rgba(246,168,33,.7); - animation:launch-pulse 2.4s ease-in-out infinite; +.launch-signal { + display: grid; + grid-template-columns: 8px auto; + column-gap: 9px; + align-items: center; + padding: 10px 13px; + border: 1px solid var(--shd-border); + background: rgba(13, 16, 17, 0.8); } -.launch-signal span,.launch-signal b{font-size:var(--shd-type-telemetry);letter-spacing:1px} -.launch-signal span{color:var(--shd-text-dim)} -.launch-signal b{color:var(--shd-orange)} -.launch-hero{ - min-height:min(680px,calc(100vh - 94px)); - display:grid; - grid-template-columns:minmax(0,1.05fr) minmax(420px,.95fr); - align-items:center; - gap:clamp(36px,7vw,110px); - padding:clamp(54px,7vh,90px) 0; +.launch-signal i { + grid-row: 1/3; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--shd-orange); + box-shadow: 0 0 10px rgba(246, 168, 33, 0.7); + animation: launch-pulse 2.4s ease-in-out infinite; } -.launch-copy{max-width:780px} -.launch-copy h1{ - margin:18px 0 22px; - font-size:clamp(58px,7.2vw,126px); - line-height:.78; - letter-spacing:-.045em; +.launch-signal span, +.launch-signal b { + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; } -.launch-copy h1 em{color:var(--shd-orange);font-style:normal} -.launch-lede{ - max-width:680px; - margin:0; - color:#a4abad; - font:clamp(17px,1.25vw,22px)/1.55 var(--shd-font-editorial); +.launch-signal span { + color: var(--shd-text-dim); } -.launch-notice{ - max-width:680px; - display:grid; - gap:4px; - margin:20px 0 0; - padding:11px 13px; - border-left:3px solid var(--shd-orange); - background:#18140d; +.launch-signal b { + color: var(--shd-orange); } -.launch-notice b,.launch-notice span{font-size:var(--shd-type-supporting)} -.launch-notice b{color:var(--shd-orange);letter-spacing:.7px} -.launch-notice span{color:var(--shd-text-muted)} -.launch-actions{display:flex;flex-wrap:wrap;gap:9px;margin-top:30px} -.launch-actions a{min-height:48px;display:flex;align-items:center;justify-content:space-between;gap:26px;padding:0 17px} -.launch-actions .secondary{border-color:#4a5152;background:rgba(14,17,18,.8)} -.launch-access-note{margin:13px 0 0;color:#687174;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1px} -.launch-core{min-width:0;display:grid;justify-items:center;gap:24px} -.launch-radar{ - position:relative; - width:min(38vw,500px); - aspect-ratio:1; - border:1px solid #3d4445; - background:radial-gradient(circle,rgba(246,168,33,.08),transparent 55%); - transform:rotate(45deg); - box-shadow:0 0 70px rgba(0,0,0,.45),inset 0 0 55px rgba(0,0,0,.5); +.launch-hero { + min-height: min(680px, calc(100vh - 94px)); + display: grid; + grid-template-columns: minmax(0, 1.05fr) minmax(420px, 0.95fr); + align-items: center; + gap: clamp(36px, 7vw, 110px); + padding: clamp(54px, 7vh, 90px) 0; } -.radar-ring{ - position:absolute; - inset:12%; - border:1px solid rgba(132,142,143,.25); - border-radius:50%; +.launch-copy { + max-width: 780px; } -.radar-ring.ring-b{inset:27%} -.radar-ring.ring-c{inset:41%;border-color:rgba(246,168,33,.5)} -.radar-sweep{ - position:absolute; - inset:8%; - border-radius:50%; - background:conic-gradient(from 0deg,transparent 0 82%,rgba(246,168,33,.14) 96%,rgba(246,168,33,.55)); - animation:radar-sweep 8s linear infinite; +.launch-copy h1 { + margin: 18px 0 22px; + font-size: clamp(58px, 7.2vw, 126px); + line-height: 0.78; + letter-spacing: -0.045em; } -.radar-core{ - position:absolute; - inset:39%; - display:grid; - place-items:center; - border:1px solid var(--shd-orange); - background:#0f1213; - transform:rotate(-45deg); - box-shadow:0 12px 30px rgba(0,0,0,.35),inset 0 0 24px rgba(246,168,33,.1); +.launch-copy h1 em { + color: var(--shd-orange); + font-style: normal; } -.radar-node{ - position:absolute; - display:flex; - align-items:center; - gap:7px; - color:#8d9597; - font-size:var(--shd-type-telemetry); - font-weight:900; - letter-spacing:1px; - transform:rotate(-45deg); +.launch-lede { + max-width: 680px; + margin: 0; + color: #a4abad; + font: clamp(17px, 1.25vw, 22px)/1.55 var(--shd-font-editorial); } -.radar-node i{width:7px;height:7px;border:1px solid var(--shd-orange);background:#211a0e} -.radar-node.node-0{left:7%;top:47%} -.radar-node.node-1{right:-4%;top:47%} -.radar-node.node-2{left:42%;top:8%} -.radar-node.node-3{left:42%;bottom:8%} -.launch-core-status{width:min(100%,500px);display:grid;gap:4px;padding:12px 14px;border-left:2px solid var(--shd-orange);background:#101314} -.launch-core-status span,.launch-core-status small{color:#687174;font-size:var(--shd-type-telemetry);letter-spacing:1px} -.launch-core-status b{font-size:var(--shd-type-supporting);letter-spacing:.5px} -.launch-channels{padding:36px 0 48px;border-top:1px solid var(--shd-border)} -.launch-channels>header{display:flex;align-items:end;justify-content:space-between;margin-bottom:16px} -.launch-channels h2{margin:4px 0 0;font-size:clamp(23px,2.4vw,36px)} -.launch-channels>header>b{color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:1px} -.launch-channels>div{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));border:1px solid var(--shd-border)} -.launch-channels article{min-height:148px;display:grid;align-content:space-between;gap:15px;padding:16px;border-left:1px solid var(--shd-border);background:rgba(16,19,20,.88)} -.launch-channels article:first-child{border-left:0} -.launch-channels article small,.launch-channels article span{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px} -.launch-channels article small{color:#697174} -.launch-channels article h3{margin:0;font-size:14px;line-height:1.35} -.launch-channels article span{width:max-content;color:#747c7e} -.launch-channels article span::before{content:"";display:inline-block;width:5px;height:5px;margin-right:6px;background:currentColor} -.launch-channels article span.available{color:var(--shd-success)} -.launch-footer{ - min-height:70px; - display:flex; - align-items:center; - justify-content:space-between; - gap:24px; - margin-top:auto; - border-top:1px solid var(--shd-border); +.launch-notice { + max-width: 680px; + display: grid; + gap: 4px; + margin: 20px 0 0; + padding: 11px 13px; + border-left: 3px solid var(--shd-orange); + background: #18140d; } -.launch-footer p,.launch-footer a{color:#626a6c;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px} -.launch-footer nav{display:flex;gap:18px} -.launch-footer a:hover,.launch-footer a:focus-visible{color:var(--shd-orange)} -@keyframes radar-sweep{to{transform:rotate(1turn)}} -@keyframes launch-pulse{50%{opacity:.45;box-shadow:0 0 3px rgba(246,168,33,.3)}} -@media(max-width:980px){ - .launch-hero{grid-template-columns:1fr;min-height:auto} - .launch-core{padding-bottom:22px} - .launch-radar{width:min(76vw,500px)} - .launch-channels>div{grid-template-columns:repeat(2,minmax(0,1fr))} - .launch-channels article:nth-child(odd){border-left:0} - .launch-channels article:last-child{grid-column:1/-1} +.launch-notice b, +.launch-notice span { + font-size: var(--shd-type-supporting); } -@media(max-width:600px){ - .launch-page{padding:0 16px} - .launch-header{min-height:78px} - .launch-signal span{display:none} - .launch-copy h1{font-size:clamp(40px,13vw,62px)} - .launch-actions{display:grid} - .launch-actions a{width:100%} - .launch-access-note{line-height:1.45;overflow-wrap:anywhere} - .radar-node{font-size:8px;letter-spacing:.55px} - .radar-node.node-1{right:6%} - .launch-channels>header,.launch-footer{align-items:flex-start;flex-direction:column} - .launch-channels>header{gap:8px} - .launch-channels>div{grid-template-columns:1fr} - .launch-channels article,.launch-channels article:nth-child(odd){border-left:0;border-top:1px solid var(--shd-border)} - .launch-channels article:first-child{border-top:0} - .launch-channels article:last-child{grid-column:auto} - .launch-footer{padding:18px 0} - .launch-footer nav{flex-wrap:wrap} +.launch-notice b { + color: var(--shd-orange); + letter-spacing: 0.7px; } -@media(prefers-reduced-motion:reduce){ - .launch-signal i,.radar-sweep{animation:none} +.launch-notice span { + color: var(--shd-text-muted); +} +.launch-actions { + display: flex; + flex-wrap: wrap; + gap: 9px; + margin-top: 30px; +} +.launch-actions a { + min-height: 48px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 26px; + padding: 0 17px; +} +.launch-actions .secondary { + border-color: #4a5152; + background: rgba(14, 17, 18, 0.8); +} +.launch-access-note { + margin: 13px 0 0; + color: #687174; + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 1px; +} +.launch-core { + min-width: 0; + display: grid; + justify-items: center; + gap: 24px; +} +.launch-radar { + position: relative; + width: min(38vw, 500px); + aspect-ratio: 1; + border: 1px solid #3d4445; + background: radial-gradient( + circle, + rgba(246, 168, 33, 0.08), + transparent 55% + ); + transform: rotate(45deg); + box-shadow: + 0 0 70px rgba(0, 0, 0, 0.45), + inset 0 0 55px rgba(0, 0, 0, 0.5); +} +.radar-ring { + position: absolute; + inset: 12%; + border: 1px solid rgba(132, 142, 143, 0.25); + border-radius: 50%; +} +.radar-ring.ring-b { + inset: 27%; +} +.radar-ring.ring-c { + inset: 41%; + border-color: rgba(246, 168, 33, 0.5); +} +.radar-sweep { + position: absolute; + inset: 8%; + border-radius: 50%; + background: conic-gradient( + from 0deg, + transparent 0 82%, + rgba(246, 168, 33, 0.14) 96%, + rgba(246, 168, 33, 0.55) + ); + animation: radar-sweep 8s linear infinite; +} +.radar-core { + position: absolute; + inset: 39%; + display: grid; + place-items: center; + border: 1px solid var(--shd-orange); + background: #0f1213; + transform: rotate(-45deg); + box-shadow: + 0 12px 30px rgba(0, 0, 0, 0.35), + inset 0 0 24px rgba(246, 168, 33, 0.1); +} +.radar-node { + position: absolute; + display: flex; + align-items: center; + gap: 7px; + color: #8d9597; + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 1px; + transform: rotate(-45deg); +} +.radar-node i { + width: 7px; + height: 7px; + border: 1px solid var(--shd-orange); + background: #211a0e; +} +.radar-node.node-0 { + left: 7%; + top: 47%; +} +.radar-node.node-1 { + right: -4%; + top: 47%; +} +.radar-node.node-2 { + left: 42%; + top: 8%; +} +.radar-node.node-3 { + left: 42%; + bottom: 8%; +} +.launch-core-status { + width: min(100%, 500px); + display: grid; + gap: 4px; + padding: 12px 14px; + border-left: 2px solid var(--shd-orange); + background: #101314; +} +.launch-core-status span, +.launch-core-status small { + color: #687174; + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; +} +.launch-core-status b { + font-size: var(--shd-type-supporting); + letter-spacing: 0.5px; +} +.launch-channels { + padding: 36px 0 48px; + border-top: 1px solid var(--shd-border); +} +.launch-channels > header { + display: flex; + align-items: end; + justify-content: space-between; + margin-bottom: 16px; +} +.launch-channels h2 { + margin: 4px 0 0; + font-size: clamp(23px, 2.4vw, 36px); +} +.launch-channels > header > b { + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; +} +.launch-channels > div { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + border: 1px solid var(--shd-border); +} +.launch-channels article { + min-height: 148px; + display: grid; + align-content: space-between; + gap: 15px; + padding: 16px; + border-left: 1px solid var(--shd-border); + background: rgba(16, 19, 20, 0.88); +} +.launch-channels article:first-child { + border-left: 0; +} +.launch-channels article small, +.launch-channels article span { + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.8px; +} +.launch-channels article small { + color: #697174; +} +.launch-channels article h3 { + margin: 0; + font-size: 14px; + line-height: 1.35; +} +.launch-channels article span { + width: max-content; + color: #747c7e; +} +.launch-channels article span::before { + content: ''; + display: inline-block; + width: 5px; + height: 5px; + margin-right: 6px; + background: currentColor; +} +.launch-channels article span.available { + color: var(--shd-success); +} +.launch-footer { + min-height: 70px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + margin-top: auto; + border-top: 1px solid var(--shd-border); +} +.launch-footer p, +.launch-footer a { + color: #626a6c; + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.8px; +} +.launch-footer nav { + display: flex; + gap: 18px; +} +.launch-footer a:hover, +.launch-footer a:focus-visible { + color: var(--shd-orange); +} +@keyframes radar-sweep { + to { + transform: rotate(1turn); + } +} +@keyframes launch-pulse { + 50% { + opacity: 0.45; + box-shadow: 0 0 3px rgba(246, 168, 33, 0.3); + } +} +@media (max-width: 980px) { + .launch-hero { + grid-template-columns: 1fr; + min-height: auto; + } + .launch-core { + padding-bottom: 22px; + } + .launch-radar { + width: min(76vw, 500px); + } + .launch-channels > div { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .launch-channels article:nth-child(odd) { + border-left: 0; + } + .launch-channels article:last-child { + grid-column: 1/-1; + } +} +@media (max-width: 600px) { + .launch-page { + padding: 0 16px; + } + .launch-header { + min-height: 78px; + } + .launch-signal span { + display: none; + } + .launch-copy h1 { + font-size: clamp(40px, 13vw, 62px); + } + .launch-actions { + display: grid; + } + .launch-actions a { + width: 100%; + } + .launch-access-note { + line-height: 1.45; + overflow-wrap: anywhere; + } + .radar-node { + font-size: 8px; + letter-spacing: 0.55px; + } + .radar-node.node-1 { + right: 6%; + } + .launch-channels > header, + .launch-footer { + align-items: flex-start; + flex-direction: column; + } + .launch-channels > header { + gap: 8px; + } + .launch-channels > div { + grid-template-columns: 1fr; + } + .launch-channels article, + .launch-channels article:nth-child(odd) { + border-left: 0; + border-top: 1px solid var(--shd-border); + } + .launch-channels article:first-child { + border-top: 0; + } + .launch-channels article:last-child { + grid-column: auto; + } + .launch-footer { + padding: 18px 0; + } + .launch-footer nav { + flex-wrap: wrap; + } +} +@media (prefers-reduced-motion: reduce) { + .launch-signal i, + .radar-sweep { + animation: none; + } } /* State the consequence of an ISAC data mutation before an editor is opened. */ -.module-toolbar>div:first-child:last-child{display:block} -.mutation-policy-strip{ - display:flex; - justify-content:flex-end; - min-height:34px; - padding:0 2px 8px; - pointer-events:none; +.module-toolbar > div:first-child:last-child { + display: block; } -.mutation-consequence{ - max-width:520px; - display:flex; - align-items:center; - gap:8px; - margin:0; - color:var(--shd-text-muted); - font-size:var(--shd-type-telemetry); - line-height:1.35; +.mutation-policy-strip { + display: flex; + justify-content: flex-end; + min-height: 34px; + padding: 0 2px 8px; + pointer-events: none; } -.mutation-consequence b{ - flex:0 0 auto; - padding:4px 6px; - border:1px solid var(--shd-orange); - color:var(--shd-orange); - font-size:inherit; - letter-spacing:.8px; +.mutation-consequence { + max-width: 520px; + display: flex; + align-items: center; + gap: 8px; + margin: 0; + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + line-height: 1.35; } -.mutation-consequence span{color:var(--shd-text-muted)} -.mutation-consequence.direct b{border-color:var(--shd-success);color:var(--shd-success)} -@media(max-width:1100px){ - .mutation-policy-strip{ - justify-content:flex-start; - padding:8px 0; - border-bottom:1px solid var(--shd-border); - } +.mutation-consequence b { + flex: 0 0 auto; + padding: 4px 6px; + border: 1px solid var(--shd-orange); + color: var(--shd-orange); + font-size: inherit; + letter-spacing: 0.8px; } -@media(max-width:600px){ - .mutation-consequence{align-items:flex-start;flex-direction:column;gap:5px} +.mutation-consequence span { + color: var(--shd-text-muted); +} +.mutation-consequence.direct b { + border-color: var(--shd-success); + color: var(--shd-success); +} +@media (max-width: 1100px) { + .mutation-policy-strip { + justify-content: flex-start; + padding: 8px 0; + border-bottom: 1px solid var(--shd-border); + } +} +@media (max-width: 600px) { + .mutation-consequence { + align-items: flex-start; + flex-direction: column; + gap: 5px; + } } /* Public database prioritizes registry lookup over gateway spectacle. */ -.community-index-page{padding-top:20px} -.community-index-hero{ - min-height:138px; - display:grid; - grid-template-columns:minmax(0,1fr) auto; - gap:28px; - align-items:center; - margin-bottom:8px; - padding:18px 24px; +.community-index-page { + padding-top: 20px; } -.community-index-hero h1{ - margin:7px 0 5px; - font-size:clamp(34px,4vw,56px); - line-height:.9; - letter-spacing:-.025em; +.community-index-hero { + min-height: 138px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 28px; + align-items: center; + margin-bottom: 8px; + padding: 18px 24px; } -.community-index-hero>div>p:last-child{ - max-width:720px; - margin:0; - font-size:var(--shd-type-supporting); - line-height:1.5; +.community-index-hero h1 { + margin: 7px 0 5px; + font-size: clamp(34px, 4vw, 56px); + line-height: 0.9; + letter-spacing: -0.025em; } -.community-index-hero .catalogue-count{ - min-width:230px; - justify-items:end; - text-align:right; +.community-index-hero > div > p:last-child { + max-width: 720px; + margin: 0; + font-size: var(--shd-type-supporting); + line-height: 1.5; } -.community-index-hero .catalogue-count strong{ - color:var(--shd-text); - font-size:var(--shd-type-supporting); - letter-spacing:.5px; +.community-index-hero .catalogue-count { + min-width: 230px; + justify-items: end; + text-align: right; } -.community-index-hero .catalogue-count span{margin:4px 0 10px} -.community-index-hero .catalogue-count .secondary{ - min-height:36px; - display:grid; - place-items:center; - padding:0 12px; - border:1px solid var(--shd-border); - color:var(--shd-text-muted); - font-size:var(--shd-type-telemetry); - font-weight:900; - letter-spacing:.7px; +.community-index-hero .catalogue-count strong { + color: var(--shd-text); + font-size: var(--shd-type-supporting); + letter-spacing: 0.5px; +} +.community-index-hero .catalogue-count span { + margin: 4px 0 10px; +} +.community-index-hero .catalogue-count .secondary { + min-height: 36px; + display: grid; + place-items: center; + padding: 0 12px; + border: 1px solid var(--shd-border); + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.7px; } .community-index-hero .catalogue-count .secondary:hover, -.community-index-hero .catalogue-count .secondary:focus-visible{ - border-color:var(--shd-orange); - color:var(--shd-orange); +.community-index-hero .catalogue-count .secondary:focus-visible { + border-color: var(--shd-orange); + color: var(--shd-orange); } -.community-index-page .database-registry-grid{grid-template-columns:repeat(4,minmax(0,1fr))} -.community-index-page .database-registry-grid>a{min-height:165px} -.community-index-page .database-registry-grid>a>header{ - align-items:center; - gap:12px; - min-height:52px; - padding:6px 10px 10px; - border-bottom:1px solid var(--shd-border); - border-left:2px solid var(--shd-orange); +.community-index-page .database-registry-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); } -.community-index-page .database-registry-grid h2{ - margin:0; - color:var(--shd-text); - font-size:clamp(20px,1.35vw,24px); - line-height:1; - letter-spacing:.3px; +.community-index-page .database-registry-grid > a { + min-height: 165px; } -.community-index-page .database-registry-grid header b{ - flex:none; - color:var(--shd-text-muted); - font-size:var(--shd-type-telemetry); - line-height:1; +.community-index-page .database-registry-grid > a > header { + align-items: center; + gap: 12px; + min-height: 52px; + padding: 6px 10px 10px; + border-bottom: 1px solid var(--shd-border); + border-left: 2px solid var(--shd-orange); } -.community-index-page .database-registry-grid p{margin:12px 0} -.community-index-page .database-registry-grid>a>footer{ - display:flex; - align-items:center; - justify-content:space-between; - gap:10px; - margin-top:auto; - padding-top:12px; +.community-index-page .database-registry-grid h2 { + margin: 0; + color: var(--shd-text); + font-size: clamp(20px, 1.35vw, 24px); + line-height: 1; + letter-spacing: 0.3px; } -.community-index-page .database-registry-grid footer small{ - color:var(--shd-text-muted); - font-size:var(--shd-type-telemetry); - letter-spacing:.7px; +.community-index-page .database-registry-grid header b { + flex: none; + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + line-height: 1; } -.community-index-page .database-registry-grid footer span{ - color:var(--shd-orange); - font-size:var(--shd-type-telemetry); - font-weight:900; - letter-spacing:.7px; +.community-index-page .database-registry-grid p { + margin: 12px 0; } -.database-global-search{ - display:grid; - grid-template-columns:minmax(220px,.45fr) minmax(0,1fr); - align-items:center; - gap:18px; - margin-bottom:8px; - padding:12px; - border:1px solid var(--shd-border); - border-left:2px solid var(--shd-orange); - background:var(--shd-terminal); +.community-index-page .database-registry-grid > a > footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-top: auto; + padding-top: 12px; } -.database-global-search>div{display:grid;gap:3px} -.database-global-search>div span, -.database-global-search>div strong{ - font-size:var(--shd-type-telemetry); - font-weight:900; - letter-spacing:.8px; +.community-index-page .database-registry-grid footer small { + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + letter-spacing: 0.7px; } -.database-global-search>div span{color:var(--shd-orange)} -.database-global-search>div strong{color:var(--shd-text)} -.database-global-search form{ - display:grid; - grid-template-columns:minmax(0,1fr) auto auto; - gap:6px; +.community-index-page .database-registry-grid footer span { + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.7px; } -.database-global-search input{ - min-width:0; - min-height:40px; - padding:0 12px; - border:1px solid var(--shd-border-strong); - background:#090b0c; - color:var(--shd-text); - font:700 var(--shd-type-supporting)/1 var(--shd-font-ui); - letter-spacing:.35px; +.database-global-search { + display: grid; + grid-template-columns: minmax(220px, 0.45fr) minmax(0, 1fr); + align-items: center; + gap: 18px; + margin-bottom: 8px; + padding: 12px; + border: 1px solid var(--shd-border); + border-left: 2px solid var(--shd-orange); + background: var(--shd-terminal); } -.database-global-search input:focus-visible{ - outline:1px solid var(--shd-orange); - outline-offset:1px; - border-color:var(--shd-orange); +.database-global-search > div { + display: grid; + gap: 3px; } -.database-global-search :is(button,a){ - min-height:40px; - display:grid; - place-items:center; - padding:0 14px; - border:1px solid var(--shd-orange); - background:var(--shd-orange); - color:#101010; - font-size:var(--shd-type-telemetry); - font-weight:900; - letter-spacing:.7px; +.database-global-search > div span, +.database-global-search > div strong { + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.8px; } -.database-global-search a{ - border-color:var(--shd-border-strong); - background:transparent; - color:var(--shd-text-muted); +.database-global-search > div span { + color: var(--shd-orange); } -.database-global-search :is(button,a):hover, -.database-global-search :is(button,a):focus-visible{filter:brightness(1.12)} -.database-global-search :is(button,a):active{transform:translateY(1px);filter:brightness(.86)} -.database-global-results{ - margin-bottom:8px; - border:1px solid var(--shd-border); - background:#0e1112; +.database-global-search > div strong { + color: var(--shd-text); } -.database-global-results>header{ - display:flex; - align-items:center; - justify-content:space-between; - gap:16px; - min-height:42px; - padding:7px 12px; - border-bottom:1px solid var(--shd-border); +.database-global-search form { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + gap: 6px; } -.database-global-results>header>div{display:flex;align-items:center;gap:10px} -.database-global-results>header span, -.database-global-results>header strong, -.database-global-results>header b{ - font-size:var(--shd-type-telemetry); - letter-spacing:.7px; +.database-global-search input { + min-width: 0; + min-height: 40px; + padding: 0 12px; + border: 1px solid var(--shd-border-strong); + background: #090b0c; + color: var(--shd-text); + font: 700 var(--shd-type-supporting)/1 var(--shd-font-ui); + letter-spacing: 0.35px; } -.database-global-results>header span{color:var(--shd-orange);font-weight:900} -.database-global-results>header strong{color:var(--shd-text)} -.database-global-results>header b{color:var(--shd-text-muted)} -.database-global-results>div{ - display:grid; - grid-template-columns:repeat(2,minmax(0,1fr)); +.database-global-search input:focus-visible { + outline: 1px solid var(--shd-orange); + outline-offset: 1px; + border-color: var(--shd-orange); } -.database-global-results>div>a{ - display:grid; - grid-template-columns:110px minmax(0,1fr) minmax(100px,.7fr) auto; - align-items:center; - gap:10px; - min-height:48px; - padding:7px 12px; - border-top:1px solid var(--shd-border); - color:var(--shd-text); +.database-global-search :is(button, a) { + min-height: 40px; + display: grid; + place-items: center; + padding: 0 14px; + border: 1px solid var(--shd-orange); + background: var(--shd-orange); + color: #101010; + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.7px; } -.database-global-results>div>a:nth-child(-n+2){border-top:0} -.database-global-results>div>a:nth-child(even){border-left:1px solid var(--shd-border)} -.database-global-results>div>a:hover, -.database-global-results>div>a:focus-visible{background:#171b1c;box-shadow:inset 2px 0 var(--shd-orange)} -.database-global-results>div>a:active{filter:brightness(.86)} -.database-global-results>div small, -.database-global-results>div span, -.database-global-results>div b{font-size:var(--shd-type-telemetry);letter-spacing:.5px} -.database-global-results>div small{color:var(--shd-orange);font-weight:900} -.database-global-results>div strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:var(--shd-type-supporting)} -.database-global-results>div span{overflow:hidden;color:var(--shd-text-muted);text-overflow:ellipsis;white-space:nowrap} -.database-global-results>div b{color:var(--shd-orange)} -.database-global-results>p{margin:0;padding:16px;color:var(--shd-text-muted);font:var(--shd-type-supporting)/1.5 var(--shd-font-editorial)} -.database-category-nav>a{grid-template-columns:minmax(0,1fr) auto} -@media(max-width:1100px){ - .community-index-page .database-registry-grid{grid-template-columns:repeat(2,minmax(0,1fr))} - .database-global-results>div{grid-template-columns:1fr} - .database-global-results>div>a:nth-child(n){border-top:1px solid var(--shd-border);border-left:0} - .database-global-results>div>a:first-child{border-top:0} +.database-global-search a { + border-color: var(--shd-border-strong); + background: transparent; + color: var(--shd-text-muted); } -@media(max-width:750px){ - .community-index-hero{grid-template-columns:1fr;gap:14px;padding:18px} - .community-index-hero .catalogue-count{min-width:0;justify-items:start;text-align:left} - .database-global-search{grid-template-columns:1fr;gap:9px} - .database-global-search form{grid-template-columns:1fr auto} - .database-global-search form input{grid-column:1/-1} - .database-global-results>header{align-items:flex-start} - .database-global-results>div>a{grid-template-columns:minmax(0,1fr) auto} - .database-global-results>div :is(small,span){grid-column:1/-1} - .community-index-page .database-registry-grid{grid-template-columns:1fr} +.database-global-search :is(button, a):hover, +.database-global-search :is(button, a):focus-visible { + filter: brightness(1.12); +} +.database-global-search :is(button, a):active { + transform: translateY(1px); + filter: brightness(0.86); +} +.database-global-results { + margin-bottom: 8px; + border: 1px solid var(--shd-border); + background: #0e1112; +} +.database-global-results > header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + min-height: 42px; + padding: 7px 12px; + border-bottom: 1px solid var(--shd-border); +} +.database-global-results > header > div { + display: flex; + align-items: center; + gap: 10px; +} +.database-global-results > header span, +.database-global-results > header strong, +.database-global-results > header b { + font-size: var(--shd-type-telemetry); + letter-spacing: 0.7px; +} +.database-global-results > header span { + color: var(--shd-orange); + font-weight: 900; +} +.database-global-results > header strong { + color: var(--shd-text); +} +.database-global-results > header b { + color: var(--shd-text-muted); +} +.database-global-results > div { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} +.database-global-results > div > a { + display: grid; + grid-template-columns: 110px minmax(0, 1fr) minmax(100px, 0.7fr) auto; + align-items: center; + gap: 10px; + min-height: 48px; + padding: 7px 12px; + border-top: 1px solid var(--shd-border); + color: var(--shd-text); +} +.database-global-results > div > a:nth-child(-n + 2) { + border-top: 0; +} +.database-global-results > div > a:nth-child(even) { + border-left: 1px solid var(--shd-border); +} +.database-global-results > div > a:hover, +.database-global-results > div > a:focus-visible { + background: #171b1c; + box-shadow: inset 2px 0 var(--shd-orange); +} +.database-global-results > div > a:active { + filter: brightness(0.86); +} +.database-global-results > div small, +.database-global-results > div span, +.database-global-results > div b { + font-size: var(--shd-type-telemetry); + letter-spacing: 0.5px; +} +.database-global-results > div small { + color: var(--shd-orange); + font-weight: 900; +} +.database-global-results > div strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--shd-type-supporting); +} +.database-global-results > div span { + overflow: hidden; + color: var(--shd-text-muted); + text-overflow: ellipsis; + white-space: nowrap; +} +.database-global-results > div b { + color: var(--shd-orange); +} +.database-global-results > p { + margin: 0; + padding: 16px; + color: var(--shd-text-muted); + font: var(--shd-type-supporting)/1.5 var(--shd-font-editorial); +} +.database-category-nav > a { + grid-template-columns: minmax(0, 1fr) auto; +} +@media (max-width: 1100px) { + .community-index-page .database-registry-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .database-global-results > div { + grid-template-columns: 1fr; + } + .database-global-results > div > a:nth-child(n) { + border-top: 1px solid var(--shd-border); + border-left: 0; + } + .database-global-results > div > a:first-child { + border-top: 0; + } +} +@media (max-width: 750px) { + .community-index-hero { + grid-template-columns: 1fr; + gap: 14px; + padding: 18px; + } + .community-index-hero .catalogue-count { + min-width: 0; + justify-items: start; + text-align: left; + } + .database-global-search { + grid-template-columns: 1fr; + gap: 9px; + } + .database-global-search form { + grid-template-columns: 1fr auto; + } + .database-global-search form input { + grid-column: 1/-1; + } + .database-global-results > header { + align-items: flex-start; + } + .database-global-results > div > a { + grid-template-columns: minmax(0, 1fr) auto; + } + .database-global-results > div :is(small, span) { + grid-column: 1/-1; + } + .community-index-page .database-registry-grid { + grid-template-columns: 1fr; + } } -.database-discovery-console{ - margin:10px 0; - border:1px solid var(--shd-border); - border-left:2px solid var(--shd-orange); - background:#0d1011; +.database-discovery-console { + margin: 10px 0; + border: 1px solid var(--shd-border); + border-left: 2px solid var(--shd-orange); + background: #0d1011; } -.database-discovery-console>.database-facet-panel{ - margin:0; - border:0; - border-left:0; +.database-discovery-console > .database-facet-panel { + margin: 0; + border: 0; + border-left: 0; } -.database-discovery-console>.registry-pagination{ - margin:0; - border:0; - border-top:1px solid var(--shd-border); - background:#101314; +.database-discovery-console > .registry-pagination { + margin: 0; + border: 0; + border-top: 1px solid var(--shd-border); + background: #101314; } -.database-discovery-console>.registry-pagination:first-child{border-top:0} -.database-facet-actions{display:flex;align-items:center;gap:5px} -.database-facet-panel>header .database-facet-toggle{ - display:flex; - align-items:center; - gap:8px; - border-color:var(--shd-border-strong); - background:#101314; - color:var(--shd-text); +.database-discovery-console > .registry-pagination:first-child { + border-top: 0; } -.database-facet-panel>header .database-facet-toggle b{ - min-width:12px; - color:var(--shd-orange); - font-size:15px; - line-height:1; +.database-facet-actions { + display: flex; + align-items: center; + gap: 5px; } -.database-facet-panel>header .database-facet-toggle:hover, -.database-facet-panel>header .database-facet-toggle:focus-visible{ - border-color:var(--shd-orange); - color:var(--shd-orange); +.database-facet-panel > header .database-facet-toggle { + display: flex; + align-items: center; + gap: 8px; + border-color: var(--shd-border-strong); + background: #101314; + color: var(--shd-text); } -.database-facet-panel.collapsed>header{border-bottom:0} -.database-facet-groups[hidden]{display:none} -@media(max-width:700px){ - .database-facet-panel>header{align-items:stretch} - .database-facet-actions{width:100%} - .database-facet-actions>button{flex:1} +.database-facet-panel > header .database-facet-toggle b { + min-width: 12px; + color: var(--shd-orange); + font-size: 15px; + line-height: 1; +} +.database-facet-panel > header .database-facet-toggle:hover, +.database-facet-panel > header .database-facet-toggle:focus-visible { + border-color: var(--shd-orange); + color: var(--shd-orange); +} +.database-facet-panel.collapsed > header { + border-bottom: 0; +} +.database-facet-groups[hidden] { + display: none; +} +@media (max-width: 700px) { + .database-facet-panel > header { + align-items: stretch; + } + .database-facet-actions { + width: 100%; + } + .database-facet-actions > button { + flex: 1; + } } /* ISAC interaction polish: distinguish controls from telemetry and preserve readable supporting data. */ -.kpi-filters>a{ - align-content:center; +.kpi-filters > a { + align-content: center; } -.kpi-filters>a>i{ - position:absolute; - top:50%; - right:9px; - padding:3px 5px; - border:1px solid var(--shd-border-strong); - color:var(--shd-text-muted); - font-size:var(--shd-type-telemetry); - font-style:normal; - font-weight:900; - letter-spacing:.55px; - transform:translateY(-50%); +.kpi-filters > a > i { + position: absolute; + top: 50%; + right: 9px; + padding: 3px 5px; + border: 1px solid var(--shd-border-strong); + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + font-style: normal; + font-weight: 900; + letter-spacing: 0.55px; + transform: translateY(-50%); } -.kpi-filters>a>strong{padding-right:76px} -.kpi-filters>a>small{color:var(--shd-text-muted)} -.kpi-filters>a.active>i{ - border-color:var(--shd-orange); - background:#211a0e; - color:var(--shd-orange); +.kpi-filters > a > strong { + padding-right: 76px; } -.kpi-filters>a:hover>i, -.kpi-filters>a:focus-visible>i{ - border-color:var(--shd-orange); - color:var(--shd-orange); +.kpi-filters > a > small { + color: var(--shd-text-muted); } -.pagination-search-state{ - min-width:82px; - height:30px; - display:grid; - place-items:center; - color:var(--shd-text-muted); - font-size:var(--shd-type-telemetry); - font-weight:900; - letter-spacing:.55px; - white-space:nowrap; +.kpi-filters > a.active > i { + border-color: var(--shd-orange); + background: #211a0e; + color: var(--shd-orange); } -.pagination-search-state.pending{color:var(--shd-orange)} -.dashboard-row:not(.table-heading):not(.mobile-detail-row):not(.pending-record):hover{background:transparent} -.dashboard-row>button:not(.mobile-row-open), -.dashboard-row>.row-action, -.dashboard-row>.table-action{ - min-height:30px; - display:inline-grid; - place-items:center; - justify-self:end; - padding:0 9px; - border:1px solid #4c3d26; - background:#17140f; - color:var(--shd-orange); - font-size:var(--shd-type-telemetry); - font-weight:900; - letter-spacing:.55px; - cursor:pointer; +.kpi-filters > a:hover > i, +.kpi-filters > a:focus-visible > i { + border-color: var(--shd-orange); + color: var(--shd-orange); } -.dashboard-row>button:not(.mobile-row-open):hover, -.dashboard-row>button:not(.mobile-row-open):focus-visible, -.dashboard-row>.row-action:hover, -.dashboard-row>.row-action:focus-visible, -.dashboard-row>.table-action:hover, -.dashboard-row>.table-action:focus-visible{ - border-color:var(--shd-orange); - background:#211a0e; +.pagination-search-state { + min-width: 82px; + height: 30px; + display: grid; + place-items: center; + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.55px; + white-space: nowrap; } -.dashboard-row>button:not(.mobile-row-open):active, -.dashboard-row>.row-action:active, -.dashboard-row>.table-action:active{ - transform:translateY(1px); - filter:brightness(.86); +.pagination-search-state.pending { + color: var(--shd-orange); } -.module-toolbar>div>p:last-child, +.dashboard-row:not(.table-heading):not(.mobile-detail-row):not( + .pending-record + ):hover { + background: transparent; +} +.dashboard-row > button:not(.mobile-row-open), +.dashboard-row > .row-action, +.dashboard-row > .table-action { + min-height: 30px; + display: inline-grid; + place-items: center; + justify-self: end; + padding: 0 9px; + border: 1px solid #4c3d26; + background: #17140f; + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.55px; + cursor: pointer; +} +.dashboard-row > button:not(.mobile-row-open):hover, +.dashboard-row > button:not(.mobile-row-open):focus-visible, +.dashboard-row > .row-action:hover, +.dashboard-row > .row-action:focus-visible, +.dashboard-row > .table-action:hover, +.dashboard-row > .table-action:focus-visible { + border-color: var(--shd-orange); + background: #211a0e; +} +.dashboard-row > button:not(.mobile-row-open):active, +.dashboard-row > .row-action:active, +.dashboard-row > .table-action:active { + transform: translateY(1px); + filter: brightness(0.86); +} +.module-toolbar > div > p:last-child, .module-kpis small, -.dashboard-row>div small, -.dashboard-row>span small, +.dashboard-row > div small, +.dashboard-row > span small, .table-heading, .pagination-range, -.pagination-sizes>span, -.database-category-command>div:last-child span, -.database-item-card>header small, +.pagination-sizes > span, +.database-category-command > div:last-child span, +.database-item-card > header small, .database-item-facts span, .database-item-lines span, -.database-item-icon-groups>div>span{color:var(--shd-text-muted)} -@media(prefers-reduced-motion:reduce){ - .dashboard-row>button:not(.mobile-row-open):active, - .dashboard-row>.row-action:active, - .dashboard-row>.table-action:active{transform:none} +.database-item-icon-groups > div > span { + color: var(--shd-text-muted); +} +@media (prefers-reduced-motion: reduce) { + .dashboard-row > button:not(.mobile-row-open):active, + .dashboard-row > .row-action:active, + .dashboard-row > .table-action:active { + transform: none; + } +} + +.image-library-editor { + margin: 10px 0; +} +.image-library-form { + display: grid; + grid-template-columns: minmax(330px, 0.8fr) minmax(440px, 1.2fr); + gap: 12px; + margin-top: 12px; +} +.image-library-upload .image-drop-input { + height: 100%; + min-height: 168px; +} +.image-library-upload .image-upload-preview { + width: 112px; + height: 112px; +} +.image-library-metadata { + display: grid; + grid-template-columns: 1fr 1fr; + align-content: start; + gap: 8px; +} +.image-library-metadata .field { + margin: 0; +} +.image-library-metadata .full, +.image-processing-options { + grid-column: 1/-1; +} +.image-processing-options { + display: grid; + grid-template-columns: 1fr 1fr; + border: 1px solid var(--shd-border); + background: #0d1011; +} +.image-processing-options label { + min-height: 58px; + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + gap: 9px; + padding: 9px; + cursor: pointer; +} +.image-processing-options label + label { + border-left: 1px solid var(--shd-border); +} +.image-processing-options input { + width: auto; + margin: 0; + accent-color: var(--shd-orange); +} +.image-processing-options span { + display: grid; + gap: 3px; +} +.image-processing-options b { + font-size: var(--shd-type-telemetry); + letter-spacing: 0.8px; +} +.image-processing-options small { + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + line-height: 1.35; +} +.image-processing-options label:has(input:checked) { + background: #1b170e; + box-shadow: inset 0 -2px var(--shd-orange); +} +.image-processing-options label.disabled { + opacity: 0.42; + cursor: not-allowed; +} +.image-metadata-edit { + display: grid; + grid-template-columns: 72px 1fr 1fr 1.4fr; + align-items: end; + gap: 9px; + margin-top: 10px; +} +.image-metadata-edit > img { + width: 72px; + height: 72px; + object-fit: contain; + padding: 5px; + border: 1px solid var(--shd-border); + background: #090b0c; +} +.image-metadata-edit .field { + margin: 0; +} +.image-library-grid { + display: grid; + grid-template-columns: repeat(4, minmax(190px, 1fr)); + gap: 8px; + margin-top: 8px; +} +.image-library-grid article { + min-width: 0; + border: 1px solid var(--shd-border); + border-top: 2px solid #4a5152; + background: var(--shd-surface); + transition: var(--shd-transition); +} +.image-library-grid article.icon { + border-top-color: var(--shd-orange); +} +.image-library-grid article:hover { + border-color: #596061; + background: #15191a; +} +.image-library-preview { + height: 150px; + display: grid; + place-items: center; + padding: 14px; + border-bottom: 1px solid var(--shd-border); + background: linear-gradient( + 135deg, + #0a0c0d 25%, + #101314 25%, + #101314 50%, + #0a0c0d 50%, + #0a0c0d 75%, + #101314 75% + ); + background-size: 16px 16px; +} +.image-library-preview img { + display: block; + max-width: 100%; + max-height: 100%; + object-fit: contain; +} +.image-library-grid header { + position: static; + height: auto; + min-height: 54px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 9px 11px; + border-bottom: 1px solid var(--shd-border); + background: transparent; +} +.image-library-grid header > div { + min-width: 0; + display: grid; + gap: 3px; +} +.image-library-grid header strong { + overflow: hidden; + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} +.image-library-grid header span, +.image-library-grid header b { + font-size: var(--shd-type-telemetry); + letter-spacing: 0.8px; +} +.image-library-grid header span { + color: var(--shd-text-muted); +} +.image-library-grid header b { + color: var(--shd-orange); +} +.image-library-tags { + min-height: 42px; + display: flex; + align-content: flex-start; + flex-wrap: wrap; + gap: 4px; + padding: 8px 10px; +} +.image-library-tags span { + width: max-content; + padding: 3px 5px; + border: 1px solid #3c4243; + color: #92999b; + font-size: var(--shd-type-telemetry); +} +.image-library-tags small { + color: var(--shd-text-dim); + font-size: var(--shd-type-telemetry); +} +.image-library-grid footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 7px 10px; + border-top: 1px solid var(--shd-border); +} +.image-library-grid footer span { + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); +} +.image-library-grid footer button { + border: 0; + background: transparent; + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); + font-weight: 900; + cursor: pointer; +} +.image-library-empty { + grid-column: 1/-1; + min-height: 180px; + display: grid; + place-content: center; + gap: 6px; + border: 1px dashed var(--shd-border); + color: var(--shd-text-muted); + text-align: center; +} +.image-library-empty strong { + color: var(--shd-text); +} +@media (max-width: 1100px) { + .image-library-grid { + grid-template-columns: repeat(3, minmax(180px, 1fr)); + } +} +@media (max-width: 800px) { + .image-library-form { + grid-template-columns: 1fr; + } + .image-library-grid { + grid-template-columns: repeat(2, minmax(150px, 1fr)); + } + .image-metadata-edit { + grid-template-columns: 72px 1fr; + } + .image-metadata-edit .field:last-child { + grid-column: 1/-1; + } +} +@media (max-width: 560px) { + .image-library-metadata, + .image-processing-options, + .image-library-grid { + grid-template-columns: 1fr; + } + .image-library-metadata .full, + .image-processing-options { + grid-column: auto; + } + .image-processing-options label + label { + border-left: 0; + border-top: 1px solid var(--shd-border); + } + .image-library-preview { + height: 132px; + } + .image-metadata-edit { + grid-template-columns: 1fr; + } + .image-metadata-edit > img { + width: 100%; + height: 96px; + } } - -.image-library-editor{margin:10px 0}.image-library-form{display:grid;grid-template-columns:minmax(330px,.8fr) minmax(440px,1.2fr);gap:12px;margin-top:12px}.image-library-upload .image-drop-input{height:100%;min-height:168px}.image-library-upload .image-upload-preview{width:112px;height:112px}.image-library-metadata{display:grid;grid-template-columns:1fr 1fr;align-content:start;gap:8px}.image-library-metadata .field{margin:0}.image-library-metadata .full,.image-processing-options{grid-column:1/-1}.image-processing-options{display:grid;grid-template-columns:1fr 1fr;border:1px solid var(--shd-border);background:#0d1011}.image-processing-options label{min-height:58px;display:grid;grid-template-columns:auto 1fr;align-items:center;gap:9px;padding:9px;cursor:pointer}.image-processing-options label+label{border-left:1px solid var(--shd-border)}.image-processing-options input{width:auto;margin:0;accent-color:var(--shd-orange)}.image-processing-options span{display:grid;gap:3px}.image-processing-options b{font-size:var(--shd-type-telemetry);letter-spacing:.8px}.image-processing-options small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.35}.image-processing-options label:has(input:checked){background:#1b170e;box-shadow:inset 0 -2px var(--shd-orange)}.image-processing-options label.disabled{opacity:.42;cursor:not-allowed}.image-metadata-edit{display:grid;grid-template-columns:72px 1fr 1fr 1.4fr;align-items:end;gap:9px;margin-top:10px}.image-metadata-edit>img{width:72px;height:72px;object-fit:contain;padding:5px;border:1px solid var(--shd-border);background:#090b0c}.image-metadata-edit .field{margin:0}.image-library-grid{display:grid;grid-template-columns:repeat(4,minmax(190px,1fr));gap:8px;margin-top:8px}.image-library-grid article{min-width:0;border:1px solid var(--shd-border);border-top:2px solid #4a5152;background:var(--shd-surface);transition:var(--shd-transition)}.image-library-grid article.icon{border-top-color:var(--shd-orange)}.image-library-grid article:hover{border-color:#596061;background:#15191a}.image-library-preview{height:150px;display:grid;place-items:center;padding:14px;border-bottom:1px solid var(--shd-border);background:linear-gradient(135deg,#0a0c0d 25%,#101314 25%,#101314 50%,#0a0c0d 50%,#0a0c0d 75%,#101314 75%);background-size:16px 16px}.image-library-preview img{display:block;max-width:100%;max-height:100%;object-fit:contain}.image-library-grid header{position:static;height:auto;min-height:54px;display:flex;align-items:center;justify-content:space-between;padding:9px 11px;border-bottom:1px solid var(--shd-border);background:transparent}.image-library-grid header>div{min-width:0;display:grid;gap:3px}.image-library-grid header strong{overflow:hidden;font-size:13px;text-overflow:ellipsis;white-space:nowrap}.image-library-grid header span,.image-library-grid header b{font-size:var(--shd-type-telemetry);letter-spacing:.8px}.image-library-grid header span{color:var(--shd-text-muted)}.image-library-grid header b{color:var(--shd-orange)}.image-library-tags{min-height:42px;display:flex;align-content:flex-start;flex-wrap:wrap;gap:4px;padding:8px 10px}.image-library-tags span{width:max-content;padding:3px 5px;border:1px solid #3c4243;color:#92999b;font-size:var(--shd-type-telemetry)}.image-library-tags small{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry)}.image-library-grid footer{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:7px 10px;border-top:1px solid var(--shd-border)}.image-library-grid footer span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry)}.image-library-grid footer button{border:0;background:transparent;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;cursor:pointer}.image-library-empty{grid-column:1/-1;min-height:180px;display:grid;place-content:center;gap:6px;border:1px dashed var(--shd-border);color:var(--shd-text-muted);text-align:center}.image-library-empty strong{color:var(--shd-text)}@media(max-width:1100px){.image-library-grid{grid-template-columns:repeat(3,minmax(180px,1fr))}}@media(max-width:800px){.image-library-form{grid-template-columns:1fr}.image-library-grid{grid-template-columns:repeat(2,minmax(150px,1fr))}.image-metadata-edit{grid-template-columns:72px 1fr}.image-metadata-edit .field:last-child{grid-column:1/-1}}@media(max-width:560px){.image-library-metadata,.image-processing-options,.image-library-grid{grid-template-columns:1fr}.image-library-metadata .full,.image-processing-options{grid-column:auto}.image-processing-options label+label{border-left:0;border-top:1px solid var(--shd-border)}.image-library-preview{height:132px}.image-metadata-edit{grid-template-columns:1fr}.image-metadata-edit>img{width:100%;height:96px}} /* Managed image library: keep previews contained and reserve clear drop-zone lanes. */ -.image-library-upload .image-drop-input{ - grid-template-columns:112px minmax(0,1fr); - grid-template-rows:auto auto; - align-content:center; - column-gap:14px; - padding:14px +.image-library-upload .image-drop-input { + grid-template-columns: 112px minmax(0, 1fr); + grid-template-rows: auto auto; + align-content: center; + column-gap: 14px; + padding: 14px; } -.image-library-upload .image-upload-preview{ - grid-column:1; - grid-row:1/3 +.image-library-upload .image-upload-preview { + grid-column: 1; + grid-row: 1/3; } -.image-library-upload .image-drop-copy{ - grid-column:2; - grid-row:1; - align-self:end +.image-library-upload .image-drop-copy { + grid-column: 2; + grid-row: 1; + align-self: end; } -.image-library-upload .image-upload-actions{ - grid-column:2; - grid-row:2; - align-self:start +.image-library-upload .image-upload-actions { + grid-column: 2; + grid-row: 2; + align-self: start; } -.image-library-preview{ - overflow:hidden; - background-color:#0a0c0d; - background-image: - linear-gradient(45deg,#111415 25%,transparent 25%,transparent 75%,#111415 75%), - linear-gradient(45deg,#111415 25%,transparent 25%,transparent 75%,#111415 75%); - background-position:0 0,8px 8px; - background-size:16px 16px +.image-library-preview { + overflow: hidden; + background-color: #0a0c0d; + background-image: + linear-gradient( + 45deg, + #111415 25%, + transparent 25%, + transparent 75%, + #111415 75% + ), + linear-gradient( + 45deg, + #111415 25%, + transparent 25%, + transparent 75%, + #111415 75% + ); + background-position: + 0 0, + 8px 8px; + background-size: 16px 16px; } -.image-library-preview img{ - width:100%; - height:100%; - max-width:none; - max-height:none +.image-library-preview img { + width: 100%; + height: 100%; + max-width: none; + max-height: none; } -.image-library-grid article.icon .image-library-preview img{ - width:104px; - height:104px +.image-library-grid article.icon .image-library-preview img { + width: 104px; + height: 104px; } -.registry-pagination:has(+ .image-library-grid){margin-bottom:var(--shd-gap-sm);border-bottom:1px solid var(--shd-border)} -.image-library-grid{margin-top:0} -.image-library-grid article{display:flex;flex-direction:column} -.image-library-grid article:hover{border-color:var(--shd-border);background:var(--shd-surface)} -.image-library-grid article.icon:hover{border-top-color:var(--shd-orange)} -.image-library-grid header strong{display:-webkit-box;overflow:hidden;font-size:var(--shd-type-supporting);line-height:1.15;text-overflow:clip;white-space:normal;-webkit-box-orient:vertical;-webkit-line-clamp:2} -.image-library-grid header b.assigned{color:var(--shd-info)} -.image-library-tags{flex:1} -.image-library-facts{min-height:34px} -.image-library-facts span:last-child{text-align:right} -.image-library-actions{display:grid;grid-template-columns:repeat(3,1fr);border-top:1px solid var(--shd-border);background:var(--shd-bg)} -.image-library-actions .table-action{min-width:0;min-height:36px;display:grid;place-items:center;padding:0 8px;border:0;border-left:1px solid var(--shd-border);background:transparent;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.6px;cursor:pointer;text-decoration:none} -.image-library-actions .table-action:first-child{border-left:0} -.image-library-actions .table-action.edit{color:var(--shd-orange)} -.image-library-actions .table-action.destructive{color:var(--shd-danger)} -.image-library-actions .table-action:hover,.image-library-actions .table-action:focus-visible{background:var(--shd-surface-active);color:var(--shd-text)} -.image-library-actions .table-action.edit:hover,.image-library-actions .table-action.edit:focus-visible{color:var(--shd-orange-hover)} -.image-library-actions .table-action.destructive:hover,.image-library-actions .table-action.destructive:focus-visible{background:color-mix(in srgb,var(--shd-danger) 12%,var(--shd-bg));color:var(--shd-danger)} -.image-library-actions .table-action:focus-visible{position:relative;z-index:1;outline:2px solid var(--shd-focus-ring);outline-offset:2px} -.image-library-delete-dialog{width:min(560px,100%);border-top-color:var(--shd-danger)} -.image-library-delete-summary{display:grid;grid-template-columns:104px minmax(0,1fr);align-items:center;gap:var(--shd-gap-md);margin-top:var(--shd-gap-md);padding:var(--shd-gap-md);border:1px solid var(--shd-border);background:var(--shd-bg)} -.image-library-delete-preview{width:104px;height:104px;display:grid;place-items:center;padding:8px;border:1px solid var(--shd-border);background:var(--shd-surface)} -.image-library-delete-preview img{display:block;max-width:100%;max-height:100%;object-fit:contain} -.image-library-delete-summary>div:last-child{display:grid;gap:var(--shd-gap-xs)} -.image-library-delete-summary b{color:var(--shd-danger);font-size:var(--shd-type-telemetry);letter-spacing:.8px} -.image-library-delete-summary p{margin:0;color:var(--shd-text-muted);font:var(--shd-type-supporting)/1.45 var(--shd-font-editorial)} -.image-library-delete-summary small{color:var(--shd-danger);font-size:var(--shd-type-telemetry)} -.image-library-delete-actions{display:grid;grid-template-columns:1fr 1.35fr;gap:var(--shd-gap-sm);margin-top:var(--shd-gap-md)} -.image-library-delete-actions form,.image-library-delete-actions fieldset,.image-library-delete-actions button{width:100%;height:100%} -.image-library-delete-actions .secondary,.image-library-delete-actions .danger{min-height:40px;padding:0 10px;font-size:var(--shd-type-telemetry)} -.image-library-delete-actions .danger{border:1px solid var(--shd-danger);background:var(--shd-danger);color:var(--shd-bg)} -@media(max-width:700px){ - .image-library-upload .image-drop-input{ - grid-template-columns:96px minmax(0,1fr); - grid-template-rows:auto auto; - padding:10px - } - .image-library-upload .image-upload-preview{ - grid-row:1; - width:96px; - height:96px - } - .image-library-upload .image-drop-copy{ - align-self:center - } - .image-library-upload .image-upload-actions{ - grid-column:1/-1; - grid-row:2 - } - .image-library-actions .table-action{min-height:44px} - .image-library-delete-actions .secondary,.image-library-delete-actions .danger{min-height:44px} +.registry-pagination:has(+ .image-library-grid) { + margin-bottom: var(--shd-gap-sm); + border-bottom: 1px solid var(--shd-border); +} +.image-library-grid { + margin-top: 0; +} +.image-library-grid article { + display: flex; + flex-direction: column; +} +.image-library-grid article:hover { + border-color: var(--shd-border); + background: var(--shd-surface); +} +.image-library-grid article.icon:hover { + border-top-color: var(--shd-orange); +} +.image-library-grid header strong { + display: -webkit-box; + overflow: hidden; + font-size: var(--shd-type-supporting); + line-height: 1.15; + text-overflow: clip; + white-space: normal; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} +.image-library-grid header b.assigned { + color: var(--shd-info); +} +.image-library-tags { + flex: 1; +} +.image-library-facts { + min-height: 34px; +} +.image-library-facts span:last-child { + text-align: right; +} +.image-library-actions { + display: grid; + grid-template-columns: repeat(3, 1fr); + border-top: 1px solid var(--shd-border); + background: var(--shd-bg); +} +.image-library-actions .table-action { + min-width: 0; + min-height: 36px; + display: grid; + place-items: center; + padding: 0 8px; + border: 0; + border-left: 1px solid var(--shd-border); + background: transparent; + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.6px; + cursor: pointer; + text-decoration: none; +} +.image-library-actions .table-action:first-child { + border-left: 0; +} +.image-library-actions .table-action.edit { + color: var(--shd-orange); +} +.image-library-actions .table-action.destructive { + color: var(--shd-danger); +} +.image-library-actions .table-action:hover, +.image-library-actions .table-action:focus-visible { + background: var(--shd-surface-active); + color: var(--shd-text); +} +.image-library-actions .table-action.edit:hover, +.image-library-actions .table-action.edit:focus-visible { + color: var(--shd-orange-hover); +} +.image-library-actions .table-action.destructive:hover, +.image-library-actions .table-action.destructive:focus-visible { + background: color-mix(in srgb, var(--shd-danger) 12%, var(--shd-bg)); + color: var(--shd-danger); +} +.image-library-actions .table-action:focus-visible { + position: relative; + z-index: 1; + outline: 2px solid var(--shd-focus-ring); + outline-offset: 2px; +} +.image-library-delete-dialog { + width: min(560px, 100%); + border-top-color: var(--shd-danger); +} +.image-library-delete-summary { + display: grid; + grid-template-columns: 104px minmax(0, 1fr); + align-items: center; + gap: var(--shd-gap-md); + margin-top: var(--shd-gap-md); + padding: var(--shd-gap-md); + border: 1px solid var(--shd-border); + background: var(--shd-bg); +} +.image-library-delete-preview { + width: 104px; + height: 104px; + display: grid; + place-items: center; + padding: 8px; + border: 1px solid var(--shd-border); + background: var(--shd-surface); +} +.image-library-delete-preview img { + display: block; + max-width: 100%; + max-height: 100%; + object-fit: contain; +} +.image-library-delete-summary > div:last-child { + display: grid; + gap: var(--shd-gap-xs); +} +.image-library-delete-summary b { + color: var(--shd-danger); + font-size: var(--shd-type-telemetry); + letter-spacing: 0.8px; +} +.image-library-delete-summary p { + margin: 0; + color: var(--shd-text-muted); + font: var(--shd-type-supporting)/1.45 var(--shd-font-editorial); +} +.image-library-delete-summary small { + color: var(--shd-danger); + font-size: var(--shd-type-telemetry); +} +.image-library-delete-actions { + display: grid; + grid-template-columns: 1fr 1.35fr; + gap: var(--shd-gap-sm); + margin-top: var(--shd-gap-md); +} +.image-library-delete-actions form, +.image-library-delete-actions fieldset, +.image-library-delete-actions button { + width: 100%; + height: 100%; +} +.image-library-delete-actions .secondary, +.image-library-delete-actions .danger { + min-height: 40px; + padding: 0 10px; + font-size: var(--shd-type-telemetry); +} +.image-library-delete-actions .danger { + border: 1px solid var(--shd-danger); + background: var(--shd-danger); + color: var(--shd-bg); +} +@media (max-width: 700px) { + .image-library-upload .image-drop-input { + grid-template-columns: 96px minmax(0, 1fr); + grid-template-rows: auto auto; + padding: 10px; + } + .image-library-upload .image-upload-preview { + grid-row: 1; + width: 96px; + height: 96px; + } + .image-library-upload .image-drop-copy { + align-self: center; + } + .image-library-upload .image-upload-actions { + grid-column: 1/-1; + grid-row: 2; + } + .image-library-actions .table-action { + min-height: 44px; + } + .image-library-delete-actions .secondary, + .image-library-delete-actions .danger { + min-height: 44px; + } +} +@media (max-width: 520px) { + .image-library-delete-summary { + grid-template-columns: 72px minmax(0, 1fr); + } + .image-library-delete-preview { + width: 72px; + height: 72px; + } + .image-library-delete-actions { + grid-template-columns: 1fr; + } } -@media(max-width:520px){.image-library-delete-summary{grid-template-columns:72px minmax(0,1fr)}.image-library-delete-preview{width:72px;height:72px}.image-library-delete-actions{grid-template-columns:1fr}} /* Specialization operations: dense identity editing and a horizontally stable focus-tree composer. */ -.specialization-toolbar{ - align-items:end +.specialization-toolbar { + align-items: end; } -.specialization-progression-console{ - margin:10px 0; - border:1px solid var(--shd-border); - border-left:2px solid #5f6a6c; - background:#0d1011 +.specialization-progression-console { + margin: 10px 0; + border: 1px solid var(--shd-border); + border-left: 2px solid #5f6a6c; + background: #0d1011; } -.specialization-progression-console[open]{ - border-left-color:var(--shd-orange) +.specialization-progression-console[open] { + border-left-color: var(--shd-orange); } -.specialization-progression-console>summary{ - min-height:54px; - cursor:pointer; - list-style:none +.specialization-progression-console > summary { + min-height: 54px; + cursor: pointer; + list-style: none; } -.specialization-progression-console>summary::-webkit-details-marker{ - display:none +.specialization-progression-console > summary::-webkit-details-marker { + display: none; } -.specialization-progression-console>summary>div:last-child{ - display:flex; - align-items:center; - gap:10px +.specialization-progression-console > summary > div:last-child { + display: flex; + align-items: center; + gap: 10px; } -.specialization-progression-console>summary i{ - color:var(--shd-orange); - font-size:18px; - font-style:normal; - transition:transform var(--shd-transition) +.specialization-progression-console > summary i { + color: var(--shd-orange); + font-size: 18px; + font-style: normal; + transition: transform var(--shd-transition); } -.specialization-progression-console[open]>summary i{ - transform:rotate(45deg) +.specialization-progression-console[open] > summary i { + transform: rotate(45deg); } -.specialization-progression-console form{ - padding:10px; - border-top:1px solid var(--shd-border) +.specialization-progression-console form { + padding: 10px; + border-top: 1px solid var(--shd-border); } -.specialization-progression-grid{ - display:grid; - grid-template-columns:minmax(280px,.72fr) minmax(520px,1.28fr); - gap:9px +.specialization-progression-grid { + display: grid; + grid-template-columns: minmax(280px, 0.72fr) minmax(520px, 1.28fr); + gap: 9px; } -.specialization-progression-grid>section{ - border:1px solid var(--shd-border); - background:#111415 +.specialization-progression-grid > section { + border: 1px solid var(--shd-border); + background: #111415; } -.specialization-progression-grid>section>header, -.specialization-rank-editor>section>header, -.specialization-ability-slots>section>header{ - min-height:36px; - display:flex; - align-items:center; - justify-content:space-between; - gap:10px; - padding:7px 9px; - border-bottom:1px solid var(--shd-border); - background:#0d1011 +.specialization-progression-grid > section > header, +.specialization-rank-editor > section > header, +.specialization-ability-slots > section > header { + min-height: 36px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 7px 9px; + border-bottom: 1px solid var(--shd-border); + background: #0d1011; } -.specialization-progression-grid>section>header span, -.specialization-progression-grid>section>header b, -.specialization-rank-editor>section>header span, -.specialization-rank-editor>section>header b, -.specialization-ability-slots>section>header span, -.specialization-ability-slots>section>header b{ - font-size:var(--shd-type-telemetry); - letter-spacing:.75px +.specialization-progression-grid > section > header span, +.specialization-progression-grid > section > header b, +.specialization-rank-editor > section > header span, +.specialization-rank-editor > section > header b, +.specialization-ability-slots > section > header span, +.specialization-ability-slots > section > header b { + font-size: var(--shd-type-telemetry); + letter-spacing: 0.75px; } -.specialization-progression-grid>section>header b, -.specialization-rank-editor>section>header b{ - color:var(--shd-orange) +.specialization-progression-grid > section > header b, +.specialization-rank-editor > section > header b { + color: var(--shd-orange); } -.specialization-progression-grid>section:first-child>div{ - display:grid; - grid-template-columns:1fr 1fr; - gap:7px; - padding:9px +.specialization-progression-grid > section:first-child > div { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 7px; + padding: 9px; } -.specialization-progression-grid>section>p{ - margin:0; - padding:0 9px 10px; - color:var(--shd-text-muted); - font-size:var(--shd-type-supporting); - line-height:1.45 +.specialization-progression-grid > section > p { + margin: 0; + padding: 0 9px 10px; + color: var(--shd-text-muted); + font-size: var(--shd-type-supporting); + line-height: 1.45; } -.specialization-rank-profile>div{ - display:grid; - grid-template-columns:repeat(3,1fr) +.specialization-rank-profile > div { + display: grid; + grid-template-columns: repeat(3, 1fr); } -.specialization-rank-profile>div>div{ - display:grid; - grid-template-columns:auto 1fr 1.2fr; - align-items:end; - gap:6px; - padding:9px +.specialization-rank-profile > div > div { + display: grid; + grid-template-columns: auto 1fr 1.2fr; + align-items: end; + gap: 6px; + padding: 9px; } -.specialization-rank-profile>div>div+div{ - border-left:1px solid var(--shd-border) +.specialization-rank-profile > div > div + div { + border-left: 1px solid var(--shd-border); } -.specialization-rank-profile>div strong{ - align-self:center; - color:var(--shd-text-muted); - font-size:var(--shd-type-telemetry); - letter-spacing:.7px +.specialization-rank-profile > div strong { + align-self: center; + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + letter-spacing: 0.7px; } -.specialization-row{ - grid-template-columns:minmax(260px,1.65fr) 100px 145px 145px 90px 72px +.specialization-row { + grid-template-columns: minmax(260px, 1.65fr) 100px 145px 145px 90px 72px; } -.specialization-row.selected{ - background:#17160f; - box-shadow:inset 2px 0 var(--shd-orange) +.specialization-row.selected { + background: #17160f; + box-shadow: inset 2px 0 var(--shd-orange); } -.specialization-row.archived{ - opacity:.68 +.specialization-row.archived { + opacity: 0.68; } -.specialization-row-identity{ - min-width:0; - display:grid; - grid-template-columns:40px minmax(0,1fr); - align-items:center; - gap:9px +.specialization-row-identity { + min-width: 0; + display: grid; + grid-template-columns: 40px minmax(0, 1fr); + align-items: center; + gap: 9px; } -.specialization-row-identity>img{ - width:40px; - height:40px; - display:block; - object-fit:contain +.specialization-row-identity > img { + width: 40px; + height: 40px; + display: block; + object-fit: contain; } -.specialization-row-identity>div{ - min-width:0; - display:grid; - gap:3px +.specialization-row-identity > div { + min-width: 0; + display: grid; + gap: 3px; } .specialization-row-identity strong, -.specialization-row-identity small{ - overflow:hidden; - text-overflow:ellipsis; - white-space:nowrap +.specialization-row-identity small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.specialization-row>span{ - display:grid; - align-content:center; - gap:2px +.specialization-row > span { + display: grid; + align-content: center; + gap: 2px; } -.specialization-row>span small{ - font-size:var(--shd-type-telemetry) +.specialization-row > span small { + font-size: var(--shd-type-telemetry); } -.specialization-workbench{ - margin-top:12px; - border:1px solid var(--shd-border); - border-top:2px solid var(--shd-orange); - background:#0b0e0f +.specialization-workbench { + margin-top: 12px; + border: 1px solid var(--shd-border); + border-top: 2px solid var(--shd-orange); + background: #0b0e0f; } -.specialization-workbench-command{ - position:static; - min-height:104px; - height:auto; - display:flex; - align-items:center; - justify-content:space-between; - gap:20px; - padding:14px; - border-bottom:1px solid var(--shd-border); - background: - linear-gradient(100deg,rgba(237,126,17,.08),transparent 42%), - #101314 +.specialization-workbench-command { + position: static; + min-height: 104px; + height: auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 14px; + border-bottom: 1px solid var(--shd-border); + background: + linear-gradient(100deg, rgba(237, 126, 17, 0.08), transparent 42%), #101314; } -.specialization-composite-identity{ - min-width:0; - display:grid; - grid-template-columns:76px minmax(0,1fr); - align-items:center; - gap:14px +.specialization-composite-identity { + min-width: 0; + display: grid; + grid-template-columns: 76px minmax(0, 1fr); + align-items: center; + gap: 14px; } -.specialization-composite-identity>img{ - width:76px; - height:76px; - display:block; - object-fit:contain; - padding:7px; - border-color:#4c3d26; - background:#0a0c0d +.specialization-composite-identity > img { + width: 76px; + height: 76px; + display: block; + object-fit: contain; + padding: 7px; + border-color: #4c3d26; + background: #0a0c0d; } -.specialization-composite-identity>div{ - min-width:0; - display:grid; - gap:4px +.specialization-composite-identity > div { + min-width: 0; + display: grid; + gap: 4px; } .specialization-composite-identity span, -.specialization-focus-command>div:first-child>span{ - color:var(--shd-orange); - font-size:var(--shd-type-telemetry); - font-weight:900; - letter-spacing:1px +.specialization-focus-command > div:first-child > span { + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 1px; } .specialization-composite-identity h3, -.specialization-focus-command h3{ - margin:0; - font-size:clamp(20px,1.8vw,28px); - letter-spacing:.55px +.specialization-focus-command h3 { + margin: 0; + font-size: clamp(20px, 1.8vw, 28px); + letter-spacing: 0.55px; } .specialization-composite-identity p, -.specialization-focus-command p{ - max-width:760px; - margin:0; - color:var(--shd-text-muted); - font-size:var(--shd-type-supporting); - line-height:1.5 +.specialization-focus-command p { + max-width: 760px; + margin: 0; + color: var(--shd-text-muted); + font-size: var(--shd-type-supporting); + line-height: 1.5; } -.specialization-workbench-actions{ - display:flex; - flex-wrap:wrap; - justify-content:flex-end; - gap:6px +.specialization-workbench-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; } -.specialization-focus-tabs{ - display:flex; - overflow-x:auto; - border-bottom:1px solid var(--shd-border); - background:#0d1011; - scrollbar-color:#505758 #15191a +.specialization-focus-tabs { + display: flex; + overflow-x: auto; + border-bottom: 1px solid var(--shd-border); + background: #0d1011; + scrollbar-color: #505758 #15191a; } -.specialization-focus-tabs>button{ - min-width:220px; - min-height:66px; - display:grid; - grid-template-columns:45px minmax(0,1fr); - align-items:center; - gap:9px; - padding:8px 12px; - border:0; - border-right:1px solid var(--shd-border); - border-bottom:2px solid transparent; - background:#101314; - color:var(--shd-text-muted); - text-align:left; - cursor:pointer +.specialization-focus-tabs > button { + min-width: 220px; + min-height: 66px; + display: grid; + grid-template-columns: 45px minmax(0, 1fr); + align-items: center; + gap: 9px; + padding: 8px 12px; + border: 0; + border-right: 1px solid var(--shd-border); + border-bottom: 2px solid transparent; + background: #101314; + color: var(--shd-text-muted); + text-align: left; + cursor: pointer; } -.specialization-focus-tabs>button:hover, -.specialization-focus-tabs>button:focus-visible{ - background:#161a1b; - color:var(--shd-text) +.specialization-focus-tabs > button:hover, +.specialization-focus-tabs > button:focus-visible { + background: #161a1b; + color: var(--shd-text); } -.specialization-focus-tabs>button.active{ - border-bottom-color:var(--shd-orange); - background:#19160f; - color:var(--shd-text) +.specialization-focus-tabs > button.active { + border-bottom-color: var(--shd-orange); + background: #19160f; + color: var(--shd-text); } -.specialization-focus-tabs>button.pending-reference{ - border-bottom-color:#c9983f +.specialization-focus-tabs > button.pending-reference { + border-bottom-color: #c9983f; } -.specialization-focus-tabs>button>span:last-child{ - min-width:0; - display:grid; - gap:4px +.specialization-focus-tabs > button > span:last-child { + min-width: 0; + display: grid; + gap: 4px; } .specialization-focus-tabs strong, -.specialization-focus-tabs small{ - overflow:hidden; - text-overflow:ellipsis; - white-space:nowrap +.specialization-focus-tabs small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.specialization-focus-tabs strong{ - font-size:13px +.specialization-focus-tabs strong { + font-size: 13px; } -.specialization-focus-tabs small{ - color:var(--shd-text-muted); - font-size:var(--shd-type-telemetry) +.specialization-focus-tabs small { + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); } -.focus-composite-icon{ - position:relative; - width:45px; - height:45px; - display:grid; - place-items:center +.focus-composite-icon { + position: relative; + width: 45px; + height: 45px; + display: grid; + place-items: center; } -.focus-composite-icon>img:not(.game-icon){ - width:42px; - height:42px; - display:block; - object-fit:contain +.focus-composite-icon > img:not(.game-icon) { + width: 42px; + height: 42px; + display: block; + object-fit: contain; } -.focus-composite-icon>.game-icon{ - position:absolute; - right:-2px; - bottom:-2px; - width:20px; - height:20px; - padding:3px; - border:1px solid #5b4b30; - background:#0b0e0f +.focus-composite-icon > .game-icon { + position: absolute; + right: -2px; + bottom: -2px; + width: 20px; + height: 20px; + padding: 3px; + border: 1px solid #5b4b30; + background: #0b0e0f; } -.specialization-focus-empty{ - min-height:66px; - display:grid; - align-content:center; - gap:3px; - padding:10px 14px +.specialization-focus-empty { + min-height: 66px; + display: grid; + align-content: center; + gap: 3px; + padding: 10px 14px; } -.specialization-focus-empty strong{ - color:var(--shd-orange); - font-size:var(--shd-type-telemetry) +.specialization-focus-empty strong { + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); } -.specialization-focus-empty span{ - color:var(--shd-text-muted); - font-size:var(--shd-type-supporting) +.specialization-focus-empty span { + color: var(--shd-text-muted); + font-size: var(--shd-type-supporting); } -.specialization-focus-command{ - display:grid; - grid-template-columns:minmax(260px,1fr) auto auto; - align-items:center; - gap:18px; - padding:13px 14px; - border-bottom:1px solid var(--shd-border); - background:#111415 +.specialization-focus-command { + display: grid; + grid-template-columns: minmax(260px, 1fr) auto auto; + align-items: center; + gap: 18px; + padding: 13px 14px; + border-bottom: 1px solid var(--shd-border); + background: #111415; } -.specialization-focus-command>div:first-child{ - display:grid; - gap:4px +.specialization-focus-command > div:first-child { + display: grid; + gap: 4px; } -.specialization-focus-telemetry{ - display:grid; - grid-template-columns:repeat(2,minmax(96px,1fr)); - border:1px solid var(--shd-border) +.specialization-focus-telemetry { + display: grid; + grid-template-columns: repeat(2, minmax(96px, 1fr)); + border: 1px solid var(--shd-border); } -.specialization-focus-telemetry span{ - display:grid; - gap:3px; - padding:7px 9px; - color:var(--shd-text-muted); - font-size:var(--shd-type-telemetry); - letter-spacing:.6px +.specialization-focus-telemetry span { + display: grid; + gap: 3px; + padding: 7px 9px; + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + letter-spacing: 0.6px; } -.specialization-focus-telemetry span:nth-child(even){ - border-left:1px solid var(--shd-border) +.specialization-focus-telemetry span:nth-child(even) { + border-left: 1px solid var(--shd-border); } -.specialization-focus-telemetry span:nth-child(n+3){ - border-top:1px solid var(--shd-border) +.specialization-focus-telemetry span:nth-child(n + 3) { + border-top: 1px solid var(--shd-border); } -.specialization-focus-telemetry b{ - color:var(--shd-text) +.specialization-focus-telemetry b { + color: var(--shd-text); } -.specialization-focus-telemetry b.complete{ - color:var(--shd-success) +.specialization-focus-telemetry b.complete { + color: var(--shd-success); } -.specialization-focus-command>.specialization-mutation{ - display:flex; - gap:6px +.specialization-focus-command > .specialization-mutation { + display: flex; + gap: 6px; } -.specialization-ability-assignment{ - margin:10px +.specialization-ability-assignment { + margin: 10px; } -.specialization-ability-slots{ - display:grid; - grid-template-columns:repeat(3,1fr); - gap:7px +.specialization-ability-slots { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 7px; } -.specialization-ability-slots>section{ - min-width:0; - border:1px solid var(--shd-border); - background:#0d1011 +.specialization-ability-slots > section { + min-width: 0; + border: 1px solid var(--shd-border); + background: #0d1011; } -.specialization-ability-slots>section>header b{ - color:var(--shd-text-muted) +.specialization-ability-slots > section > header b { + color: var(--shd-text-muted); } -.specialization-ability-slots .searchable-assignment{ - margin:8px +.specialization-ability-slots .searchable-assignment { + margin: 8px; } -.specialization-ability-slots p{ - min-height:52px; - margin:0; - padding:0 9px 9px; - color:var(--shd-text-muted); - font-size:var(--shd-type-supporting); - line-height:1.4 +.specialization-ability-slots p { + min-height: 52px; + margin: 0; + padding: 0 9px 9px; + color: var(--shd-text-muted); + font-size: var(--shd-type-supporting); + line-height: 1.4; } -.specialization-ability-summary{ - display:grid; - grid-template-columns:repeat(3,1fr); - gap:7px; - padding:10px; - border-bottom:1px solid var(--shd-border) +.specialization-ability-summary { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 7px; + padding: 10px; + border-bottom: 1px solid var(--shd-border); } -.specialization-ability-summary>article{ - position:relative; - min-width:0; - display:grid; - grid-template-columns:54px minmax(0,1fr) auto; - align-items:center; - gap:9px; - padding:9px; - border:1px solid var(--shd-border); - border-top:2px solid #596164; - background:#111415 +.specialization-ability-summary > article { + position: relative; + min-width: 0; + display: grid; + grid-template-columns: 54px minmax(0, 1fr) auto; + align-items: center; + gap: 9px; + padding: 9px; + border: 1px solid var(--shd-border); + border-top: 2px solid #596164; + background: #111415; } -.specialization-ability-summary>article.tree-linked{ - border-top-color:#9a6c27 +.specialization-ability-summary > article.tree-linked { + border-top-color: #9a6c27; } -.specialization-ability-summary>article>img{ - width:54px; - height:54px; - display:block; - object-fit:contain +.specialization-ability-summary > article > img { + width: 54px; + height: 54px; + display: block; + object-fit: contain; } -.specialization-ability-summary article>div{ - min-width:0; - display:grid; - gap:4px +.specialization-ability-summary article > div { + min-width: 0; + display: grid; + gap: 4px; } -.specialization-ability-summary span{ - color:var(--shd-orange); - font-size:var(--shd-type-telemetry); - font-weight:900 +.specialization-ability-summary span { + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); + font-weight: 900; } -.specialization-ability-summary strong{ - font-size:13px +.specialization-ability-summary strong { + font-size: 13px; } -.specialization-ability-summary .ability-chip-readiness{ - color:var(--shd-danger); - font-size:var(--shd-type-telemetry); - font-weight:900; - letter-spacing:.55px +.specialization-ability-summary .ability-chip-readiness { + color: var(--shd-danger); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.55px; } -.specialization-ability-summary .ability-chip-readiness.ready{ - color:var(--shd-success) +.specialization-ability-summary .ability-chip-readiness.ready { + color: var(--shd-success); } -.specialization-ability-summary p{ - display:-webkit-box; - overflow:hidden; - margin:0; - color:var(--shd-text-muted); - font-size:var(--shd-type-telemetry); - line-height:1.4; - -webkit-box-orient:vertical; - -webkit-line-clamp:2 +.specialization-ability-summary p { + display: -webkit-box; + overflow: hidden; + margin: 0; + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + line-height: 1.4; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; } /* Exclusive child-record assignments stay close to identity and expose capacity. */ -.skill-chip-ability-assignment{ - margin-top:10px; - padding:9px; - border:1px solid var(--shd-border); - border-left:2px solid var(--shd-orange); - background:#0d1011 +.skill-chip-ability-assignment { + margin-top: 10px; + padding: 9px; + border: 1px solid var(--shd-border); + border-left: 2px solid var(--shd-orange); + background: #0d1011; } -.skill-chip-ability-assignment>.assignment-picker{ - margin-top:7px +.skill-chip-ability-assignment > .assignment-picker { + margin-top: 7px; } -.skill-chip-row{ - min-width:1240px; - grid-template-columns:1.05fr 1.15fr .4fr 1fr 1fr .78fr .48fr .3fr +.skill-chip-row { + min-width: 1240px; + grid-template-columns: 1.05fr 1.15fr 0.4fr 1fr 1fr 0.78fr 0.48fr 0.3fr; } -.skill-chip-row>div:nth-child(5){ - display:grid; - gap:3px +.skill-chip-row > div:nth-child(5) { + display: grid; + gap: 3px; } -.skill-chip-row>div:nth-child(5) small, -.skill-chip-ability-cell small{ - color:#626a6c; - font-size:var(--shd-type-telemetry) +.skill-chip-row > div:nth-child(5) small, +.skill-chip-ability-cell small { + color: #626a6c; + font-size: var(--shd-type-telemetry); } -.skill-chip-ability-cell{ - min-width:0; - display:grid; - gap:3px +.skill-chip-ability-cell { + min-width: 0; + display: grid; + gap: 3px; } -.skill-chip-ability-cell strong{ - overflow:hidden; - font-size:var(--shd-type-telemetry); - line-height:1.35; - text-overflow:ellipsis +.skill-chip-ability-cell strong { + overflow: hidden; + font-size: var(--shd-type-telemetry); + line-height: 1.35; + text-overflow: ellipsis; } -@media(max-width:700px){ - .skill-chip-ability-assignment{ - padding:8px - } +@media (max-width: 700px) { + .skill-chip-ability-assignment { + padding: 8px; + } } -.specialization-ability-summary button{ - align-self:start; - border:0; - background:transparent; - color:var(--shd-orange); - font-size:var(--shd-type-telemetry); - font-weight:900; - cursor:pointer +.specialization-ability-summary button { + align-self: start; + border: 0; + background: transparent; + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); + font-weight: 900; + cursor: pointer; } -.specialization-tree-console{ - margin:10px; - border:1px solid var(--shd-border); - background:#090b0c +.specialization-tree-console { + margin: 10px; + border: 1px solid var(--shd-border); + background: #090b0c; } -.specialization-tree-console>header{ - position:static; - height:auto; - min-height:54px; - display:flex; - align-items:center; - justify-content:space-between; - gap:16px; - padding:9px 11px; - border-bottom:1px solid var(--shd-border); - background:#111415 +.specialization-tree-console > header { + position: static; + height: auto; + min-height: 54px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 9px 11px; + border-bottom: 1px solid var(--shd-border); + background: #111415; } -.specialization-tree-console>header>div{ - display:grid; - gap:3px +.specialization-tree-console > header > div { + display: grid; + gap: 3px; } -.specialization-tree-console>header span, -.specialization-tree-console>header small{ - color:var(--shd-text-muted); - font-size:var(--shd-type-telemetry); - letter-spacing:.7px +.specialization-tree-console > header span, +.specialization-tree-console > header small { + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + letter-spacing: 0.7px; } -.specialization-tree-console>header h3{ - margin:0; - font-size:16px +.specialization-tree-console > header h3 { + margin: 0; + font-size: 16px; } -.specialization-tree-console>header b{ - color:var(--shd-orange); - font-size:var(--shd-type-telemetry) +.specialization-tree-console > header b { + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); } -.specialization-tree-scroll{ - overflow-x:auto; - padding:10px; - scrollbar-color:#596164 #141718 +.specialization-tree-scroll { + overflow-x: auto; + padding: 10px; + scrollbar-color: #596164 #141718; } -.specialization-tree{ - min-width:1180px; - display:grid; - gap:8px +.specialization-tree { + min-width: 1180px; + display: grid; + gap: 8px; } -.specialization-tree-row{ - display:grid; - grid-template-columns:160px minmax(930px,1fr) 160px; - align-items:stretch; - border:1px solid var(--shd-border); - background:#0e1112 +.specialization-tree-row { + display: grid; + grid-template-columns: 160px minmax(930px, 1fr) 160px; + align-items: stretch; + border: 1px solid var(--shd-border); + background: #0e1112; } -.specialization-tree-row>header{ - position:static; - height:auto; - display:grid; - align-content:center; - gap:5px; - padding:10px; - border-right:1px solid var(--shd-border); - background:#111415 +.specialization-tree-row > header { + position: static; + height: auto; + display: grid; + align-content: center; + gap: 5px; + padding: 10px; + border-right: 1px solid var(--shd-border); + background: #111415; } -.specialization-tree-row>header span{ - color:var(--shd-orange); - font-size:var(--shd-type-telemetry); - font-weight:900; - letter-spacing:1px +.specialization-tree-row > header span { + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 1px; } -.specialization-tree-row>header b{ - color:var(--shd-text-muted); - font-size:var(--shd-type-telemetry); - line-height:1.35 +.specialization-tree-row > header b { + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + line-height: 1.35; } -.specialization-tree-track{ - position:relative; - display:grid; - grid-template-columns:repeat(12,minmax(70px,1fr)); - gap:6px; - align-items:center; - min-height:112px; - padding:8px; - background-image:linear-gradient(90deg,transparent calc(100% - 1px),rgba(255,255,255,.035) 1px); - background-size:calc(100% / 12) 100% +.specialization-tree-track { + position: relative; + display: grid; + grid-template-columns: repeat(12, minmax(70px, 1fr)); + gap: 6px; + align-items: center; + min-height: 112px; + padding: 8px; + background-image: linear-gradient( + 90deg, + transparent calc(100% - 1px), + rgba(255, 255, 255, 0.035) 1px + ); + background-size: calc(100% / 12) 100%; } -.specialization-tree-node{ - position:relative; - min-width:0; - height:94px; - display:grid; - grid-template-rows:42px minmax(0,1fr) auto; - justify-items:center; - gap:3px; - padding:6px 4px; - border:1px solid #3c4243; - border-top:2px solid #687274; - background:#141718; - text-align:center +.specialization-tree-node { + position: relative; + min-width: 0; + height: 94px; + display: grid; + grid-template-rows: 42px minmax(0, 1fr) auto; + justify-items: center; + gap: 3px; + padding: 6px 4px; + border: 1px solid #3c4243; + border-top: 2px solid #687274; + background: #141718; + text-align: center; } -.specialization-tree-node.free{ - border-top-color:var(--shd-orange); - background:#19160f +.specialization-tree-node.free { + border-top-color: var(--shd-orange); + background: #19160f; } -.specialization-tree-node.pending-record{ - border-style:dashed; - border-top-color:#c9983f +.specialization-tree-node.pending-record { + border-style: dashed; + border-top-color: #c9983f; } -.specialization-tree-node>img{ - width:40px; - height:40px; - display:block; - object-fit:contain +.specialization-tree-node > img { + width: 40px; + height: 40px; + display: block; + object-fit: contain; } -.specialization-tree-node>span{ - width:100%; - overflow:hidden; - color:var(--shd-text); - font-size:var(--shd-type-telemetry); - font-weight:900; - text-overflow:ellipsis; - white-space:nowrap +.specialization-tree-node > span { + width: 100%; + overflow: hidden; + color: var(--shd-text); + font-size: var(--shd-type-telemetry); + font-weight: 900; + text-overflow: ellipsis; + white-space: nowrap; } -.specialization-tree-node>b{ - color:var(--shd-text-muted); - font-size:var(--shd-type-telemetry) +.specialization-tree-node > b { + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); } -.specialization-tree-node>button{ - position:absolute; - inset:0; - border:0; - background:rgba(11,14,15,.92); - color:var(--shd-orange); - font-size:var(--shd-type-telemetry); - font-weight:900; - opacity:0; - cursor:pointer +.specialization-tree-node > button { + position: absolute; + inset: 0; + border: 0; + background: rgba(11, 14, 15, 0.92); + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); + font-weight: 900; + opacity: 0; + cursor: pointer; } -.specialization-tree-node:hover>button, -.specialization-tree-node:focus-within>button{ - opacity:1 +.specialization-tree-node:hover > button, +.specialization-tree-node:focus-within > button { + opacity: 1; } -.specialization-tree-add{ - min-height:100%; - border:0; - border-left:1px solid var(--shd-border) +.specialization-tree-add { + min-height: 100%; + border: 0; + border-left: 1px solid var(--shd-border); } -.specialization-add-row{ - min-height:64px; - display:grid; - grid-template-columns:auto auto 1fr; - align-items:center; - justify-content:center; - gap:8px; - padding:10px; - border:1px dashed #4e5658; - background:#0d1011; - color:var(--shd-text-muted); - cursor:pointer +.specialization-add-row { + min-height: 64px; + display: grid; + grid-template-columns: auto auto 1fr; + align-items: center; + justify-content: center; + gap: 8px; + padding: 10px; + border: 1px dashed #4e5658; + background: #0d1011; + color: var(--shd-text-muted); + cursor: pointer; } -.specialization-add-row>span{ - color:var(--shd-orange); - font-size:18px +.specialization-add-row > span { + color: var(--shd-orange); + font-size: 18px; } -.specialization-add-row>b, -.specialization-add-row>small{ - font-size:var(--shd-type-telemetry); - letter-spacing:.65px +.specialization-add-row > b, +.specialization-add-row > small { + font-size: var(--shd-type-telemetry); + letter-spacing: 0.65px; } -.specialization-add-row>small{ - justify-self:start +.specialization-add-row > small { + justify-self: start; } .specialization-add-row:hover, -.specialization-add-row:focus-visible{ - border-color:var(--shd-orange); - background:#17140f; - color:var(--shd-text) +.specialization-add-row:focus-visible { + border-color: var(--shd-orange); + background: #17140f; + color: var(--shd-text); } -.specialization-record-drawer{ - margin:0 10px 10px; - border:1px solid var(--shd-border); - background:#0d1011 +.specialization-record-drawer { + margin: 0 10px 10px; + border: 1px solid var(--shd-border); + background: #0d1011; } -.specialization-record-drawer>summary{ - min-height:40px; - display:flex; - align-items:center; - justify-content:space-between; - gap:10px; - padding:8px 10px; - color:var(--shd-text-muted); - font-size:var(--shd-type-telemetry); - font-weight:900; - letter-spacing:.75px; - cursor:pointer; - list-style:none +.specialization-record-drawer > summary { + min-height: 40px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 8px 10px; + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.75px; + cursor: pointer; + list-style: none; } -.specialization-record-drawer>summary::-webkit-details-marker{ - display:none +.specialization-record-drawer > summary::-webkit-details-marker { + display: none; } -.specialization-record-drawer>summary b{ - color:var(--shd-orange) +.specialization-record-drawer > summary b { + color: var(--shd-orange); } -.specialization-record-drawer>div{ - display:grid; - grid-template-columns:repeat(3,minmax(0,1fr)); - gap:6px; - padding:7px; - border-top:1px solid var(--shd-border) +.specialization-record-drawer > div { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; + padding: 7px; + border-top: 1px solid var(--shd-border); } -.specialization-record-drawer article{ - min-width:0; - display:grid; - grid-template-columns:40px minmax(0,1fr) auto; - align-items:center; - gap:8px; - padding:7px; - border:1px solid var(--shd-border); - background:#111415 +.specialization-record-drawer article { + min-width: 0; + display: grid; + grid-template-columns: 40px minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + padding: 7px; + border: 1px solid var(--shd-border); + background: #111415; } -.specialization-record-drawer article.archived{ - opacity:.68 +.specialization-record-drawer article.archived { + opacity: 0.68; } -.specialization-record-drawer article>img{ - width:40px; - height:40px; - display:block; - object-fit:contain +.specialization-record-drawer article > img { + width: 40px; + height: 40px; + display: block; + object-fit: contain; } -.specialization-record-drawer article>div{ - min-width:0; - display:grid; - gap:3px +.specialization-record-drawer article > div { + min-width: 0; + display: grid; + gap: 3px; } .specialization-record-drawer strong, -.specialization-record-drawer small{ - overflow:hidden; - text-overflow:ellipsis; - white-space:nowrap +.specialization-record-drawer small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .specialization-record-drawer small, -.specialization-record-drawer>div>p{ - color:var(--shd-text-muted); - font-size:var(--shd-type-telemetry) +.specialization-record-drawer > div > p { + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); } -.specialization-record-drawer>div>p{ - grid-column:1/-1; - margin:0; - padding:10px +.specialization-record-drawer > div > p { + grid-column: 1/-1; + margin: 0; + padding: 10px; } .specialization-identity-editor, .specialization-focus-editor, .specialization-ability-editor, -.specialization-node-editor{ - margin:10px 0 +.specialization-node-editor { + margin: 10px 0; } -.specialization-record-identity{ - display:grid; - grid-template-columns:220px minmax(0,1fr); - align-items:start; - gap:8px +.specialization-record-identity { + display: grid; + grid-template-columns: 220px minmax(0, 1fr); + align-items: start; + gap: 8px; } -.specialization-record-identity>div:last-child{ - display:grid; - grid-template-columns:minmax(260px,.65fr) minmax(360px,1.35fr); - gap:8px +.specialization-record-identity > div:last-child { + display: grid; + grid-template-columns: minmax(260px, 0.65fr) minmax(360px, 1.35fr); + gap: 8px; } -.specialization-record-identity>div:last-child>.field:last-child{ - grid-column:2; - grid-row:1/3 +.specialization-record-identity > div:last-child > .field:last-child { + grid-column: 2; + grid-row: 1/3; } -.specialization-record-identity textarea{ - min-height:100% +.specialization-record-identity textarea { + min-height: 100%; } -.specialization-identity-editor .specialization-record-identity>div:last-child{ - grid-template-columns:1fr; - grid-template-rows:auto 1fr; - align-self:stretch +.specialization-identity-editor + .specialization-record-identity + > div:last-child { + grid-template-columns: 1fr; + grid-template-rows: auto 1fr; + align-self: stretch; } -.specialization-identity-editor .specialization-record-identity>div:last-child>.field:last-child{ - grid-column:auto; - grid-row:auto +.specialization-identity-editor + .specialization-record-identity + > div:last-child + > .field:last-child { + grid-column: auto; + grid-row: auto; } -.specialization-identity-editor .specialization-record-identity textarea{ - min-height:126px +.specialization-identity-editor .specialization-record-identity textarea { + min-height: 126px; } -.specialization-focus-identity{ - display:grid; - grid-template-columns:minmax(260px,1fr) minmax(260px,1fr) 120px; - gap:8px +.specialization-focus-identity { + display: grid; + grid-template-columns: minmax(260px, 1fr) minmax(260px, 1fr) 120px; + gap: 8px; } -.specialization-focus-identity>.segmented-choice, -.specialization-focus-identity>.focus-description{ - grid-column:1/-1 +.specialization-focus-identity > .segmented-choice, +.specialization-focus-identity > .focus-description { + grid-column: 1/-1; } -.specialization-focus-identity .focus-description textarea{ - min-height:72px +.specialization-focus-identity .focus-description textarea { + min-height: 72px; } -.specialization-ability-mechanics{ - display:grid; - grid-template-columns:200px 200px minmax(280px,1fr); - gap:8px; - margin-top:8px +.specialization-ability-mechanics { + display: grid; + grid-template-columns: 200px 200px minmax(280px, 1fr); + gap: 8px; + margin-top: 8px; } -.specialization-ability-mechanics>div{ - display:grid; - align-content:center; - gap:3px; - padding:8px; - border:1px solid var(--shd-border); - background:#0d1011 +.specialization-ability-mechanics > div { + display: grid; + align-content: center; + gap: 3px; + padding: 8px; + border: 1px solid var(--shd-border); + background: #0d1011; } .specialization-ability-mechanics span, -.specialization-ability-mechanics small{ - color:var(--shd-text-muted); - font-size:var(--shd-type-telemetry) +.specialization-ability-mechanics small { + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); } -.specialization-ability-mechanics strong{ - color:var(--shd-orange); - font-size:var(--shd-type-telemetry) +.specialization-ability-mechanics strong { + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); } -.specialization-parent-lock>strong{ - min-height:37px; - display:flex; - align-items:center; - padding:7px 9px; - border:1px solid var(--shd-border); - background:#101314; - color:var(--shd-text); - font-size:var(--shd-type-supporting); - letter-spacing:.35px +.specialization-parent-lock > strong { + min-height: 37px; + display: flex; + align-items: center; + padding: 7px 9px; + border: 1px solid var(--shd-border); + background: #101314; + color: var(--shd-text); + font-size: var(--shd-type-supporting); + letter-spacing: 0.35px; } -.specialization-node-position{ - display:grid; - grid-template-columns:110px 110px minmax(300px,1fr); - gap:8px +.specialization-node-position { + display: grid; + grid-template-columns: 110px 110px minmax(300px, 1fr); + gap: 8px; } -.specialization-node-template{ - margin-top:8px +.specialization-node-template { + margin-top: 8px; } -.specialization-rank-editor{ - display:grid; - grid-template-columns:repeat(3,minmax(0,1fr)); - gap:8px; - margin-top:8px +.specialization-rank-editor { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + margin-top: 8px; } -.specialization-rank-editor>section{ - min-width:0; - border:1px solid var(--shd-border); - background:#111415 +.specialization-rank-editor > section { + min-width: 0; + border: 1px solid var(--shd-border); + background: #111415; } -.specialization-rank-editor>section>header>div{ - display:grid; - gap:3px +.specialization-rank-editor > section > header > div { + display: grid; + gap: 3px; } -.specialization-rank-editor>section>header small{ - color:var(--shd-text-muted); - font-size:var(--shd-type-telemetry) +.specialization-rank-editor > section > header small { + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); } -.specialization-rank-editor>section>.field{ - margin:8px +.specialization-rank-editor > section > .field { + margin: 8px; } -.specialization-rank-editor textarea{ - min-height:74px +.specialization-rank-editor textarea { + min-height: 74px; } -.specialization-component-costs{ - display:grid; - grid-template-columns:repeat(3,1fr); - gap:7px; - margin:8px; - padding:8px; - border:1px solid #344e65; - background:#0d1318 +.specialization-component-costs { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 7px; + margin: 8px; + padding: 8px; + border: 1px solid #344e65; + background: #0d1318; } -.specialization-component-costs legend{ - padding:0 5px; - color:#77b6e9; - font-size:var(--shd-type-telemetry); - font-weight:900 +.specialization-component-costs legend { + padding: 0 5px; + color: #77b6e9; + font-size: var(--shd-type-telemetry); + font-weight: 900; } -.specialization-component-costs .field span{ - display:flex; - align-items:center; - justify-content:space-between; - gap:4px +.specialization-component-costs .field span { + display: flex; + align-items: center; + justify-content: space-between; + gap: 4px; } -.specialization-component-costs small{ - color:#77b6e9; - font-size:var(--shd-type-telemetry) +.specialization-component-costs small { + color: #77b6e9; + font-size: var(--shd-type-telemetry); } .specialization-omega-cost, -.specialization-credit-only{ - min-height:60px; - display:flex; - align-items:center; - justify-content:space-between; - gap:9px; - margin:8px; - padding:8px; - border:1px solid var(--shd-border); - background:#0d1011 +.specialization-credit-only { + min-height: 60px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 9px; + margin: 8px; + padding: 8px; + border: 1px solid var(--shd-border); + background: #0d1011; } -.quality-marker.highend{ - display:flex; - align-items:center; - gap:8px; - color:#f2c443; - font-size:var(--shd-type-telemetry); - font-weight:900 +.quality-marker.highend { + display: flex; + align-items: center; + gap: 8px; + color: #f2c443; + font-size: var(--shd-type-telemetry); + font-weight: 900; } .specialization-omega-cost p, -.specialization-credit-only span{ - margin:0; - color:var(--shd-text-muted); - font-size:var(--shd-type-telemetry) +.specialization-credit-only span { + margin: 0; + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); } -.specialization-credit-only b{ - color:var(--shd-orange); - font-size:var(--shd-type-telemetry) +.specialization-credit-only b { + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); } -.structured-effect-editor{ - display:grid; - gap:7px; - padding:8px +.structured-effect-editor { + display: grid; + gap: 7px; + padding: 8px; } -.structured-effect-row{ - min-width:0; - border:1px solid var(--shd-border); - background:#0d1011 +.structured-effect-row { + min-width: 0; + border: 1px solid var(--shd-border); + background: #0d1011; } -.structured-effect-row>header{ - position:static; - height:auto; - min-height:32px; - display:flex; - align-items:center; - justify-content:space-between; - padding:6px 8px; - border-bottom:1px solid var(--shd-border) +.structured-effect-row > header { + position: static; + height: auto; + min-height: 32px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 8px; + border-bottom: 1px solid var(--shd-border); } -.structured-effect-row>header strong, -.structured-effect-row>header button{ - font-size:var(--shd-type-telemetry); - letter-spacing:.7px +.structured-effect-row > header strong, +.structured-effect-row > header button { + font-size: var(--shd-type-telemetry); + letter-spacing: 0.7px; } -.structured-effect-grid{ - display:grid; - grid-template-columns:minmax(260px,1.1fr) minmax(240px,1fr) minmax(170px,.8fr) 120px minmax(240px,1fr); - align-items:end; - gap:7px; - padding:8px +.structured-effect-grid { + display: grid; + grid-template-columns: + minmax(260px, 1.1fr) minmax(240px, 1fr) minmax(170px, 0.8fr) + 120px minmax(240px, 1fr); + align-items: end; + gap: 7px; + padding: 8px; } -.structured-effect-grid>.segmented-choice{ - grid-column:auto +.structured-effect-grid > .segmented-choice { + grid-column: auto; } -.effect-custom-target{ - min-height:49px; - display:grid; - align-content:center; - gap:3px; - padding:7px; - border:1px solid var(--shd-border); - background:#111415 +.effect-custom-target { + min-height: 49px; + display: grid; + align-content: center; + gap: 3px; + padding: 7px; + border: 1px solid var(--shd-border); + background: #111415; } .effect-custom-target span, -.effect-custom-target small{ - font-size:var(--shd-type-telemetry) +.effect-custom-target small { + font-size: var(--shd-type-telemetry); } -.effect-custom-target small{ - color:var(--shd-text-muted) +.effect-custom-target small { + color: var(--shd-text-muted); } -.structured-effect-empty{ - min-height:72px; - display:grid; - place-content:center; - gap:4px; - border:1px dashed var(--shd-border); - color:var(--shd-text-muted); - text-align:center +.structured-effect-empty { + min-height: 72px; + display: grid; + place-content: center; + gap: 4px; + border: 1px dashed var(--shd-border); + color: var(--shd-text-muted); + text-align: center; } .structured-effect-empty strong, -.structured-effect-empty span{ - font-size:var(--shd-type-telemetry) +.structured-effect-empty span { + font-size: var(--shd-type-telemetry); } -.structured-effect-add{ - justify-self:start +.structured-effect-add { + justify-self: start; } -.specialization-proposal-preview .specialization-fields{ - grid-template-columns:repeat(2,minmax(0,1fr)) +.specialization-proposal-preview .specialization-fields { + grid-template-columns: repeat(2, minmax(0, 1fr)); } .proposal-structured-list, -.proposal-rank-preview{ - display:grid; - gap:5px; - padding:0 8px 8px +.proposal-rank-preview { + display: grid; + gap: 5px; + padding: 0 8px 8px; } -.proposal-structured-list>header, -.proposal-rank-preview section>header{ - position:static; - height:auto; - min-height:30px; - display:flex; - align-items:center; - justify-content:space-between; - gap:8px; - padding:6px 8px; - border:1px solid var(--shd-border); - background:#111415 +.proposal-structured-list > header, +.proposal-rank-preview section > header { + position: static; + height: auto; + min-height: 30px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 6px 8px; + border: 1px solid var(--shd-border); + background: #111415; } .proposal-structured-list span, .proposal-structured-list b, .proposal-rank-preview span, -.proposal-rank-preview b{ - font-size:var(--shd-type-telemetry); - letter-spacing:.65px +.proposal-rank-preview b { + font-size: var(--shd-type-telemetry); + letter-spacing: 0.65px; } .proposal-structured-list b, -.proposal-rank-preview b{ - color:var(--shd-orange) +.proposal-rank-preview b { + color: var(--shd-orange); } .proposal-structured-list p, .proposal-rank-preview p, -.proposal-rank-preview small{ - margin:0; - padding:6px 8px; - border:1px solid var(--shd-border); - color:var(--shd-text-muted); - font-size:var(--shd-type-telemetry); - overflow-wrap:anywhere +.proposal-rank-preview small { + margin: 0; + padding: 6px 8px; + border: 1px solid var(--shd-border); + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + overflow-wrap: anywhere; } -.proposal-rank-preview section{ - display:grid; - gap:3px +.proposal-rank-preview section { + display: grid; + gap: 3px; } -.data-readonly .specialization-mutation{ - display:none!important +.data-readonly .specialization-mutation { + display: none !important; } -@media(max-width:1280px){ - .specialization-rank-editor{ - grid-template-columns:1fr - } - .structured-effect-grid{ - grid-template-columns:repeat(2,minmax(0,1fr)) - } - .specialization-ability-summary{ - grid-template-columns:1fr - } +@media (max-width: 1280px) { + .specialization-rank-editor { + grid-template-columns: 1fr; + } + .structured-effect-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .specialization-ability-summary { + grid-template-columns: 1fr; + } } -@media(max-width:1050px){ - .specialization-progression-grid{ - grid-template-columns:1fr - } - .specialization-rank-profile>div>div{ - grid-template-columns:auto 1fr 1fr - } - .specialization-row{ - grid-template-columns:minmax(230px,1.5fr) 80px 120px 120px 84px 68px - } - .specialization-focus-command{ - grid-template-columns:1fr auto - } - .specialization-focus-command>div:first-child{ - grid-column:1/-1 - } - .specialization-ability-slots{ - grid-template-columns:1fr - } - .specialization-record-drawer>div{ - grid-template-columns:1fr 1fr - } - .specialization-record-identity>div:last-child{ - grid-template-columns:1fr - } - .specialization-record-identity>div:last-child>.field:last-child{ - grid-column:auto; - grid-row:auto - } +@media (max-width: 1050px) { + .specialization-progression-grid { + grid-template-columns: 1fr; + } + .specialization-rank-profile > div > div { + grid-template-columns: auto 1fr 1fr; + } + .specialization-row { + grid-template-columns: minmax(230px, 1.5fr) 80px 120px 120px 84px 68px; + } + .specialization-focus-command { + grid-template-columns: 1fr auto; + } + .specialization-focus-command > div:first-child { + grid-column: 1/-1; + } + .specialization-ability-slots { + grid-template-columns: 1fr; + } + .specialization-record-drawer > div { + grid-template-columns: 1fr 1fr; + } + .specialization-record-identity > div:last-child { + grid-template-columns: 1fr; + } + .specialization-record-identity > div:last-child > .field:last-child { + grid-column: auto; + grid-row: auto; + } } -@media(max-width:800px){ - .specialization-workbench-command{ - align-items:stretch; - flex-direction:column - } - .specialization-workbench-actions{ - justify-content:flex-start - } - .specialization-focus-command{ - grid-template-columns:1fr - } - .specialization-focus-command>div:first-child{ - grid-column:auto - } - .specialization-focus-command>.specialization-mutation{ - width:100% - } - .specialization-focus-command>.specialization-mutation>button{ - flex:1 - } - .specialization-record-identity, - .specialization-focus-identity, - .specialization-ability-mechanics, - .specialization-node-position{ - grid-template-columns:1fr - } - .specialization-focus-identity>.segmented-choice, - .specialization-focus-identity>.focus-description{ - grid-column:auto - } - .specialization-component-costs{ - grid-template-columns:1fr - } - .specialization-proposal-preview{ - grid-template-columns:1fr - } +@media (max-width: 800px) { + .specialization-workbench-command { + align-items: stretch; + flex-direction: column; + } + .specialization-workbench-actions { + justify-content: flex-start; + } + .specialization-focus-command { + grid-template-columns: 1fr; + } + .specialization-focus-command > div:first-child { + grid-column: auto; + } + .specialization-focus-command > .specialization-mutation { + width: 100%; + } + .specialization-focus-command > .specialization-mutation > button { + flex: 1; + } + .specialization-record-identity, + .specialization-focus-identity, + .specialization-ability-mechanics, + .specialization-node-position { + grid-template-columns: 1fr; + } + .specialization-focus-identity > .segmented-choice, + .specialization-focus-identity > .focus-description { + grid-column: auto; + } + .specialization-component-costs { + grid-template-columns: 1fr; + } + .specialization-proposal-preview { + grid-template-columns: 1fr; + } } -@media(max-width:620px){ - .specialization-progression-console>summary{ - align-items:flex-start; - flex-direction:column - } - .specialization-progression-console>summary>div:last-child{ - width:100%; - justify-content:space-between - } - .specialization-progression-grid>section:first-child>div, - .specialization-rank-profile>div{ - grid-template-columns:1fr - } - .specialization-rank-profile>div>div+div{ - border-top:1px solid var(--shd-border); - border-left:0 - } - .specialization-composite-identity{ - grid-template-columns:58px minmax(0,1fr) - } - .specialization-composite-identity>img{ - width:58px; - height:58px - } - .specialization-focus-tabs>button{ - min-width:190px - } - .specialization-focus-telemetry{ - grid-template-columns:1fr 1fr; - width:100% - } - .specialization-record-drawer>div, - .specialization-proposal-preview .specialization-fields, - .structured-effect-grid{ - grid-template-columns:1fr - } - .specialization-record-drawer article{ - grid-template-columns:40px minmax(0,1fr) - } - .specialization-record-drawer article>button{ - grid-column:1/-1; - min-height:44px - } - .specialization-workbench-actions>button, - .specialization-focus-command>.specialization-mutation>button, - .specialization-tree-add, - .specialization-add-row{ - min-height:46px - } +@media (max-width: 620px) { + .specialization-progression-console > summary { + align-items: flex-start; + flex-direction: column; + } + .specialization-progression-console > summary > div:last-child { + width: 100%; + justify-content: space-between; + } + .specialization-progression-grid > section:first-child > div, + .specialization-rank-profile > div { + grid-template-columns: 1fr; + } + .specialization-rank-profile > div > div + div { + border-top: 1px solid var(--shd-border); + border-left: 0; + } + .specialization-composite-identity { + grid-template-columns: 58px minmax(0, 1fr); + } + .specialization-composite-identity > img { + width: 58px; + height: 58px; + } + .specialization-focus-tabs > button { + min-width: 190px; + } + .specialization-focus-telemetry { + grid-template-columns: 1fr 1fr; + width: 100%; + } + .specialization-record-drawer > div, + .specialization-proposal-preview .specialization-fields, + .structured-effect-grid { + grid-template-columns: 1fr; + } + .specialization-record-drawer article { + grid-template-columns: 40px minmax(0, 1fr); + } + .specialization-record-drawer article > button { + grid-column: 1/-1; + min-height: 44px; + } + .specialization-workbench-actions > button, + .specialization-focus-command > .specialization-mutation > button, + .specialization-tree-add, + .specialization-add-row { + min-height: 46px; + } } -@media(prefers-reduced-motion:reduce){ - .specialization-progression-console>summary i{ - transition:none - } +@media (prefers-reduced-motion: reduce) { + .specialization-progression-console > summary i { + transition: none; + } } /* Attribute narratives span the dense editor and Oversight comparison surfaces. */ -.attribute-editor .attribute-description{ - grid-column:1/-1 +.attribute-editor .attribute-description { + grid-column: 1/-1; } -.attribute-editor .attribute-description textarea{ - min-height:72px +.attribute-editor .attribute-description textarea { + min-height: 72px; } -.attribute-row>div:first-child{ - min-width:0 +.attribute-row > div:first-child { + min-width: 0; } -.attribute-row>div:first-child small{ - overflow:hidden; - text-overflow:ellipsis; - white-space:nowrap +.attribute-row > div:first-child small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.proposal-attribute-grid .preview-field.description{ - grid-column:span 4 +.proposal-attribute-grid .preview-field.description { + grid-column: span 4; } -@media(max-width:520px){ - .proposal-attribute-grid .preview-field.description{ - grid-column:auto - } +@media (max-width: 520px) { + .proposal-attribute-grid .preview-field.description { + grid-column: auto; + } } /* Linked field and section validation for dense ISAC editors. */ -.validation-summary{display:grid;gap:7px;margin:10px 0;padding:10px 12px;border:1px solid color-mix(in srgb,var(--shd-danger) 55%,var(--shd-border));border-left:2px solid var(--shd-danger);background:color-mix(in srgb,var(--shd-danger) 8%,var(--shd-surface));scroll-margin-top:calc(var(--shd-header-height) + 12px)} -.validation-summary:focus-visible{outline:1px solid var(--shd-danger);outline-offset:2px} -.validation-summary>strong{color:var(--shd-danger);font-size:var(--shd-type-telemetry);letter-spacing:1px} -.validation-summary ul{display:grid;gap:4px;margin:0;padding:0;list-style:none} -.validation-summary a{display:grid;grid-template-columns:minmax(120px,.32fr) minmax(0,1fr);gap:8px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.4} -.validation-summary a:hover,.validation-summary a:focus-visible{color:var(--shd-text)} -.validation-summary a span{color:var(--shd-danger);font-weight:900;text-transform:uppercase;letter-spacing:.6px} -.validation-area-error{scroll-margin-top:calc(var(--shd-header-height) + 12px)} -.validation-area-error.field,.validation-area-error>.field,.validation-area-error .icon-upload-field,.validation-area-error .mechanic-editor{border-color:var(--shd-danger)!important} -.field-error{display:block;margin:5px 0 0;color:var(--shd-danger);font-size:var(--shd-type-telemetry);line-height:1.4} -.mechanic-field-error{padding-left:10px;border-left:2px solid var(--shd-danger)} -@media(max-width:560px){.validation-summary a{grid-template-columns:1fr;gap:1px}} +.validation-summary { + display: grid; + gap: 7px; + margin: 10px 0; + padding: 10px 12px; + border: 1px solid color-mix(in srgb, var(--shd-danger) 55%, var(--shd-border)); + border-left: 2px solid var(--shd-danger); + background: color-mix(in srgb, var(--shd-danger) 8%, var(--shd-surface)); + scroll-margin-top: calc(var(--shd-header-height) + 12px); +} +.validation-summary:focus-visible { + outline: 1px solid var(--shd-danger); + outline-offset: 2px; +} +.validation-summary > strong { + color: var(--shd-danger); + font-size: var(--shd-type-telemetry); + letter-spacing: 1px; +} +.validation-summary ul { + display: grid; + gap: 4px; + margin: 0; + padding: 0; + list-style: none; +} +.validation-summary a { + display: grid; + grid-template-columns: minmax(120px, 0.32fr) minmax(0, 1fr); + gap: 8px; + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + line-height: 1.4; +} +.validation-summary a:hover, +.validation-summary a:focus-visible { + color: var(--shd-text); +} +.validation-summary a span { + color: var(--shd-danger); + font-weight: 900; + text-transform: uppercase; + letter-spacing: 0.6px; +} +.validation-area-error { + scroll-margin-top: calc(var(--shd-header-height) + 12px); +} +.validation-area-error.field, +.validation-area-error > .field, +.validation-area-error .icon-upload-field, +.validation-area-error .mechanic-editor { + border-color: var(--shd-danger) !important; +} +.field-error { + display: block; + margin: 5px 0 0; + color: var(--shd-danger); + font-size: var(--shd-type-telemetry); + line-height: 1.4; +} +.mechanic-field-error { + padding-left: 10px; + border-left: 2px solid var(--shd-danger); +} +@media (max-width: 560px) { + .validation-summary a { + grid-template-columns: 1fr; + gap: 1px; + } +} /* Collapsible modeled-data workspace for capture-first ISAC editors. */ -.modeling-disclosure{margin-top:10px;border:1px solid var(--shd-border);background:var(--shd-surface)} -.modeling-disclosure>summary{min-height:52px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:9px 11px;list-style:none;background:var(--shd-surface-raised);cursor:pointer} -.modeling-disclosure>summary::-webkit-details-marker{display:none} -.modeling-disclosure>summary span{display:grid;gap:3px} -.modeling-disclosure>summary b,.modeling-disclosure>summary strong{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.9px} -.modeling-disclosure>summary small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:.5px} -.modeling-disclosure>summary strong{color:var(--shd-orange)} -.modeling-disclosure>summary:hover,.modeling-disclosure>summary:focus-visible{background:var(--shd-surface-active);box-shadow:inset 2px 0 var(--shd-orange);outline:none} -.modeling-disclosure[open]>summary{border-bottom:1px solid var(--shd-border);box-shadow:inset 2px 0 var(--shd-orange)} -.modeling-disclosure.validation-area-error{border-color:var(--shd-danger)} -.modeling-disclosure.validation-area-error>summary{box-shadow:inset 2px 0 var(--shd-danger)} -.modeling-content{padding:10px} -@media(max-width:560px){.modeling-disclosure>summary{min-height:56px}.modeling-content{padding:7px}} -@media(prefers-reduced-motion:reduce){.modeling-disclosure>summary{transition:none}} +.modeling-disclosure { + margin-top: 10px; + border: 1px solid var(--shd-border); + background: var(--shd-surface); +} +.modeling-disclosure > summary { + min-height: 52px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 9px 11px; + list-style: none; + background: var(--shd-surface-raised); + cursor: pointer; +} +.modeling-disclosure > summary::-webkit-details-marker { + display: none; +} +.modeling-disclosure > summary span { + display: grid; + gap: 3px; +} +.modeling-disclosure > summary b, +.modeling-disclosure > summary strong { + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.9px; +} +.modeling-disclosure > summary small { + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + letter-spacing: 0.5px; +} +.modeling-disclosure > summary strong { + color: var(--shd-orange); +} +.modeling-disclosure > summary:hover, +.modeling-disclosure > summary:focus-visible { + background: var(--shd-surface-active); + box-shadow: inset 2px 0 var(--shd-orange); + outline: none; +} +.modeling-disclosure[open] > summary { + border-bottom: 1px solid var(--shd-border); + box-shadow: inset 2px 0 var(--shd-orange); +} +.modeling-disclosure.validation-area-error { + border-color: var(--shd-danger); +} +.modeling-disclosure.validation-area-error > summary { + box-shadow: inset 2px 0 var(--shd-danger); +} +.modeling-content { + padding: 10px; +} +@media (max-width: 560px) { + .modeling-disclosure > summary { + min-height: 56px; + } + .modeling-content { + padding: 7px; + } +} +@media (prefers-reduced-motion: reduce) { + .modeling-disclosure > summary { + transition: none; + } +} /* Optional visual identity in shared assignment options. */ -.assignment-item-icon{flex:0 0 auto;object-fit:contain;background:var(--shd-bg)} -.assignment-chip-icon{width:24px;height:24px} -.assignment-results>button.with-icon{min-height:44px;display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:9px} -.assignment-result-icon{width:32px;height:32px} -.assignment-results>button.with-icon>span{min-width:0;overflow-wrap:anywhere} -.assignment-results>button:disabled,.assignment-results>button:disabled:hover{cursor:not-allowed;opacity:.58;background:var(--shd-bg);color:var(--shd-text-muted);box-shadow:none;transform:none;filter:none} -.assignment-picker.visual-grid .assignment-results{display:grid;grid-template-columns:repeat(auto-fill,minmax(52px,1fr));grid-auto-rows:52px;gap:2px;max-height:none;padding:2px;overflow:visible;background:var(--shd-border-soft)} -.assignment-picker.visual-grid .assignment-results>button.visual-grid-option{position:relative;min-width:0;min-height:44px;display:grid;place-items:center;padding:5px;border:0;background:var(--shd-surface)} -.assignment-grid-icon{width:38px;height:38px} -.assignment-picker.visual-grid .assignment-results>button.visual-grid-option::after{content:attr(data-tooltip);position:absolute;left:50%;bottom:calc(100% + 6px);z-index:30;width:max-content;max-width:210px;padding:5px 7px;border:1px solid var(--shd-border-strong);background:var(--shd-bg);color:var(--shd-text);font-size:var(--shd-type-telemetry);font-weight:700;line-height:1.25;white-space:normal;opacity:0;pointer-events:none;transform:translateX(-50%)} -.assignment-picker.visual-grid .assignment-results>button.visual-grid-option:hover::after,.assignment-picker.visual-grid .assignment-results>button.visual-grid-option:focus-visible::after{opacity:1} -.assignment-picker.visual-grid .assignment-results>button.visual-grid-option:hover{background:var(--shd-surface-active);box-shadow:inset 0 0 0 1px var(--shd-border-strong)} -.assignment-picker.visual-grid .assignment-results>button.visual-grid-option:focus-visible{outline:2px solid var(--shd-focus-ring);outline-offset:-2px} -.assignment-picker.visual-grid .assignment-results>button.visual-grid-option.selected{background:var(--shd-surface-active);box-shadow:inset 0 0 0 2px var(--shd-orange)} -.assignment-picker.toggle-grid{border:0;background:transparent} -.assignment-picker.toggle-grid .assignment-results{display:grid;grid-template-columns:repeat(auto-fit,minmax(94px,1fr));gap:5px;max-height:none;overflow:visible} -.assignment-picker.toggle-grid .assignment-results>button.toggle-grid-option{min-width:0;min-height:44px;display:grid;grid-template-columns:minmax(0,1fr);padding:7px 9px;border:1px solid var(--shd-border);background:var(--shd-surface);color:var(--shd-text-muted)} -.assignment-picker.toggle-grid .assignment-results>button.toggle-grid-option>span{min-width:0;font-size:var(--shd-type-supporting);font-weight:700;overflow-wrap:anywhere} -.assignment-picker.toggle-grid .assignment-results>button.toggle-grid-option:hover{border-color:var(--shd-border-strong);background:var(--shd-surface-active);color:var(--shd-text)} -.assignment-picker.toggle-grid .assignment-results>button.toggle-grid-option:focus-visible{outline:2px solid var(--shd-focus-ring);outline-offset:2px} -.assignment-picker.toggle-grid .assignment-results>button.toggle-grid-option.selected{border-color:var(--shd-orange);background:var(--shd-surface-active);color:var(--shd-orange);box-shadow:inset 2px 0 var(--shd-orange)} -@media(max-width:560px){.assignment-picker.visual-grid .assignment-results{grid-template-columns:repeat(auto-fill,minmax(48px,1fr))}.assignment-picker.toggle-grid .assignment-results{grid-template-columns:repeat(2,minmax(0,1fr))}} +.assignment-item-icon { + flex: 0 0 auto; + object-fit: contain; + background: var(--shd-bg); +} +.assignment-chip-icon { + width: 24px; + height: 24px; +} +.assignment-results > button.with-icon { + min-height: 44px; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 9px; +} +.assignment-result-icon { + width: 32px; + height: 32px; +} +.assignment-results > button.with-icon > span { + min-width: 0; + overflow-wrap: anywhere; +} +.assignment-results > button:disabled, +.assignment-results > button:disabled:hover { + cursor: not-allowed; + opacity: 0.58; + background: var(--shd-bg); + color: var(--shd-text-muted); + box-shadow: none; + transform: none; + filter: none; +} +.assignment-picker.visual-grid .assignment-results { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(52px, 1fr)); + grid-auto-rows: 52px; + gap: 2px; + max-height: none; + padding: 2px; + overflow: visible; + background: var(--shd-border-soft); +} +.assignment-picker.visual-grid .assignment-results > button.visual-grid-option { + position: relative; + min-width: 0; + min-height: 44px; + display: grid; + place-items: center; + padding: 5px; + border: 0; + background: var(--shd-surface); +} +.assignment-grid-icon { + width: 38px; + height: 38px; +} +.assignment-picker.visual-grid + .assignment-results + > button.visual-grid-option::after { + content: attr(data-tooltip); + position: absolute; + left: 50%; + bottom: calc(100% + 6px); + z-index: 30; + width: max-content; + max-width: 210px; + padding: 5px 7px; + border: 1px solid var(--shd-border-strong); + background: var(--shd-bg); + color: var(--shd-text); + font-size: var(--shd-type-telemetry); + font-weight: 700; + line-height: 1.25; + white-space: normal; + opacity: 0; + pointer-events: none; + transform: translateX(-50%); +} +.assignment-picker.visual-grid + .assignment-results + > button.visual-grid-option:hover::after, +.assignment-picker.visual-grid + .assignment-results + > button.visual-grid-option:focus-visible::after { + opacity: 1; +} +.assignment-picker.visual-grid + .assignment-results + > button.visual-grid-option:hover { + background: var(--shd-surface-active); + box-shadow: inset 0 0 0 1px var(--shd-border-strong); +} +.assignment-picker.visual-grid + .assignment-results + > button.visual-grid-option:focus-visible { + outline: 2px solid var(--shd-focus-ring); + outline-offset: -2px; +} +.assignment-picker.visual-grid + .assignment-results + > button.visual-grid-option.selected { + background: var(--shd-surface-active); + box-shadow: inset 0 0 0 2px var(--shd-orange); +} +.assignment-picker.toggle-grid { + border: 0; + background: transparent; +} +.assignment-picker.toggle-grid .assignment-results { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(94px, 1fr)); + gap: 5px; + max-height: none; + overflow: visible; +} +.assignment-picker.toggle-grid .assignment-results > button.toggle-grid-option { + min-width: 0; + min-height: 44px; + display: grid; + grid-template-columns: minmax(0, 1fr); + padding: 7px 9px; + border: 1px solid var(--shd-border); + background: var(--shd-surface); + color: var(--shd-text-muted); +} +.assignment-picker.toggle-grid + .assignment-results + > button.toggle-grid-option + > span { + min-width: 0; + font-size: var(--shd-type-supporting); + font-weight: 700; + overflow-wrap: anywhere; +} +.assignment-picker.toggle-grid + .assignment-results + > button.toggle-grid-option:hover { + border-color: var(--shd-border-strong); + background: var(--shd-surface-active); + color: var(--shd-text); +} +.assignment-picker.toggle-grid + .assignment-results + > button.toggle-grid-option:focus-visible { + outline: 2px solid var(--shd-focus-ring); + outline-offset: 2px; +} +.assignment-picker.toggle-grid + .assignment-results + > button.toggle-grid-option.selected { + border-color: var(--shd-orange); + background: var(--shd-surface-active); + color: var(--shd-orange); + box-shadow: inset 2px 0 var(--shd-orange); +} +@media (max-width: 560px) { + .assignment-picker.visual-grid .assignment-results { + grid-template-columns: repeat(auto-fill, minmax(48px, 1fr)); + } + .assignment-picker.toggle-grid .assignment-results { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} /* Shared weapon-category bonus profiles. */ -.weapon-bonus-registry{margin-top:10px} -.weapon-bonus-profile-grid>button header strong{display:flex;align-items:center;gap:6px} -.weapon-bonus-editor{border-top-color:var(--shd-success)} -.weapon-bonus-fields{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:7px;margin-top:9px} -.weapon-mod-slot-note{display:flex;align-items:center;gap:10px;margin:8px 0;padding:8px 10px;border:1px solid #74552a;background:#18150d;color:#a89568;font-size:var(--shd-type-telemetry);line-height:1.45}.weapon-mod-slot-note b{flex:0 0 auto;color:var(--shd-orange);letter-spacing:.8px}.weapon-mod-slot-config{margin-top:8px;padding:9px;border:1px solid var(--shd-border);background:#0d1011}.weapon-mod-slot-config b.config-required,.state-dot.config-required{color:var(--shd-orange)}.weapon-t0-exclusion{margin-top:10px;padding-top:9px;border-top:1px solid var(--shd-border)}.weapon-t0-exclusion>.segmented-choice{margin-top:7px}.weapon-t0-exclusion>.segmented-choice>div{height:auto;min-height:44px}@media(max-width:700px){.weapon-mod-slot-note{align-items:flex-start;flex-direction:column;gap:4px}.weapon-t0-exclusion>.segmented-choice>div{grid-template-columns:repeat(2,minmax(0,1fr))!important}} -.weapon-bonus-fields .field{margin:0} -@media(max-width:700px){.weapon-bonus-fields{grid-template-columns:1fr}} +.weapon-bonus-registry { + margin-top: 10px; +} +.weapon-bonus-profile-grid > button header strong { + display: flex; + align-items: center; + gap: 6px; +} +.weapon-bonus-editor { + border-top-color: var(--shd-success); +} +.weapon-bonus-fields { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 7px; + margin-top: 9px; +} +.weapon-mod-slot-note { + display: flex; + align-items: center; + gap: 10px; + margin: 8px 0; + padding: 8px 10px; + border: 1px solid #74552a; + background: #18150d; + color: #a89568; + font-size: var(--shd-type-telemetry); + line-height: 1.45; +} +.weapon-mod-slot-note b { + flex: 0 0 auto; + color: var(--shd-orange); + letter-spacing: 0.8px; +} +.weapon-mod-slot-config { + margin-top: 8px; + padding: 9px; + border: 1px solid var(--shd-border); + background: #0d1011; +} +.weapon-mod-slot-config b.config-required, +.state-dot.config-required { + color: var(--shd-orange); +} +.weapon-t0-exclusion { + margin-top: 10px; + padding-top: 9px; + border-top: 1px solid var(--shd-border); +} +.weapon-t0-exclusion > .segmented-choice { + margin-top: 7px; +} +.weapon-t0-exclusion > .segmented-choice > div { + height: auto; + min-height: 44px; +} +@media (max-width: 700px) { + .weapon-mod-slot-note { + align-items: flex-start; + flex-direction: column; + gap: 4px; + } + .weapon-t0-exclusion > .segmented-choice > div { + grid-template-columns: repeat(2, minmax(0, 1fr)) !important; + } +} +.weapon-bonus-fields .field { + margin: 0; +} +@media (max-width: 700px) { + .weapon-bonus-fields { + grid-template-columns: 1fr; + } +} diff --git a/src/app.d.ts b/src/app.d.ts index 4933f0a..a69b1b5 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -1,16 +1,16 @@ // See https://svelte.dev/docs/kit/types#app.d.ts // for information about these interfaces declare global { - namespace App { - interface Error { - message: string; - errorId?: string; - } - // interface Locals {} - // interface PageData {} - // interface PageState {} - // interface Platform {} - } + namespace App { + interface Error { + message: string; + errorId?: string; + } + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } } export {}; diff --git a/src/app.html b/src/app.html index 6a2bb58..b6a3073 100644 --- a/src/app.html +++ b/src/app.html @@ -1,12 +1,12 @@ - - - - - %sveltekit.head% - - -
%sveltekit.body%
- + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ diff --git a/src/error.html b/src/error.html index 18ed73d..1626242 100644 --- a/src/error.html +++ b/src/error.html @@ -1,12 +1,140 @@ - - - - %sveltekit.status% · SHD.build system fault - - -
ISAC FALLBACK CHANNELCORE SHELL INTERRUPTED
%sveltekit.status%
SHD NETWORK RESPONSE

SYSTEM INTERFACE UNAVAILABLE

%sveltekit.error.message%

RETURN TO SHD.BUILD →
FAULT ISOLATED · CORE SERVICES PROTECTED
+ + + + %sveltekit.status% · SHD.build system fault + + + +
+
+
+ ISAC FALLBACK CHANNELCORE SHELL INTERRUPTED +
+ %sveltekit.status% +
+
+ SHD NETWORK RESPONSE +

SYSTEM INTERFACE UNAVAILABLE

+

%sveltekit.error.message%

+ RETURN TO SHD.BUILD → +
+
FAULT ISOLATED · CORE SERVICES PROTECTED
+
+ diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 0493132..623b6a8 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -2,89 +2,151 @@ import { json, type Handle, type HandleServerError } from '@sveltejs/kit'; import { stringify } from 'devalue'; import { randomUUID } from 'node:crypto'; import { getSessionUser } from '$lib/server/auth/session'; -import { getSiteAccessBlock, getSiteAccessSettings } from '$lib/server/site-access'; -import { DATA_MODULES, proposalActionResult, queueDataProposal, shouldModerateDataMutation, type DataModule } from '$lib/server/data-moderation'; +import { + getSiteAccessBlock, + getSiteAccessSettings, +} from '$lib/server/site-access'; +import { + DATA_MODULES, + proposalActionResult, + queueDataProposal, + shouldModerateDataMutation, + type DataModule, +} from '$lib/server/data-moderation'; export const handle: Handle = async ({ event, resolve }) => { - if (event.url.pathname === '/admin' || event.url.pathname.startsWith('/admin/')) { - const location = `/isac${event.url.pathname.slice('/admin'.length)}${event.url.search}`; - return new Response(null, { status: 308, headers: { location } }); - } - const siteSettings = await getSiteAccessSettings(); - const sessionUser = await getSessionUser(event.cookies); - const accessBlock = getSiteAccessBlock(siteSettings, event.url.pathname, Boolean(sessionUser)); - if (accessBlock) { - const target = new URL('/coming-soon', event.url); - if (accessBlock.kind === 'section') target.searchParams.set('unavailable', accessBlock.section); - if (event.request.method === 'GET' || event.request.method === 'HEAD') { - return new Response(null, { status: 303, headers: { location: `${target.pathname}${target.search}` } }); - } - return json( - { - error: accessBlock.kind === 'section' - ? 'This section is not available.' - : 'The public site is not available yet.' - }, - { status: 404 } - ); - } - if (event.request.method === 'POST') { - const match = event.url.pathname.match(/^\/isac\/([^/]+)$/); - const module = match?.[1] as DataModule | undefined; - const action = event.url.search.startsWith('?/') ? event.url.search.slice(2) : ''; - if (module && DATA_MODULES.includes(module) && action) { - // Publishing calculation rules is never a contributor proposal. The - // action has its own dedicated permission and confirmation contract. - if (module === 'calculations' && action === 'publishRevision') return resolve(event); - const user = sessionUser; - const access = await shouldModerateDataMutation(user); - if (!access.canManage && access.canPropose && user) { - if (!access.contributorEnabled) { - if (event.request.headers.get('accept')?.includes('application/json')) { - return json({ type: 'failure', status: 403, data: { contributorModeRequired: true } }, { status: 403 }); - } - return new Response('Contributor mode must be enabled before submitting data changes.', { status: 403 }); - } - const submittedForm = await event.request.formData(); - const proposal = await queueDataProposal(user, module, action, submittedForm); - if (event.request.headers.get('accept')?.includes('application/json')) { - const result = proposalActionResult(module, action, Boolean(proposal.entityId), proposal.id); - if (module === 'attributes' && action === 'save') { - Object.assign(result, { - savedItem: { - id: proposal.entityId ?? proposal.draftEntityId, - name: String(submittedForm.get('name') ?? ''), - type: String(submittedForm.get('type') ?? ''), - category: String(submittedForm.get('category') ?? ''), - calculation_type: String(submittedForm.get('calculationType') ?? ''), - unit: String(submittedForm.get('unit') ?? ''), - active: 1, - pending: true, - proposalId: proposal.id, - proposerName: user.displayName - } - }); - } - return json({ - type: 'success', - status: 200, - data: stringify(result) - }); - } - const target = new URL(event.url.pathname, event.url); - target.searchParams.set('proposal', proposal.id); - return new Response(null, { status: 303, headers: { location: `${target.pathname}${target.search}` } }); - } - } - } - return resolve(event); + if ( + event.url.pathname === '/admin' || + event.url.pathname.startsWith('/admin/') + ) { + const location = `/isac${event.url.pathname.slice('/admin'.length)}${event.url.search}`; + return new Response(null, { status: 308, headers: { location } }); + } + const siteSettings = await getSiteAccessSettings(); + const sessionUser = await getSessionUser(event.cookies); + const accessBlock = getSiteAccessBlock( + siteSettings, + event.url.pathname, + Boolean(sessionUser), + ); + if (accessBlock) { + const target = new URL('/coming-soon', event.url); + if (accessBlock.kind === 'section') + target.searchParams.set('unavailable', accessBlock.section); + if (event.request.method === 'GET' || event.request.method === 'HEAD') { + return new Response(null, { + status: 303, + headers: { location: `${target.pathname}${target.search}` }, + }); + } + return json( + { + error: + accessBlock.kind === 'section' + ? 'This section is not available.' + : 'The public site is not available yet.', + }, + { status: 404 }, + ); + } + if (event.request.method === 'POST') { + const match = event.url.pathname.match(/^\/isac\/([^/]+)$/); + const module = match?.[1] as DataModule | undefined; + const action = event.url.search.startsWith('?/') + ? event.url.search.slice(2) + : ''; + if (module && DATA_MODULES.includes(module) && action) { + // Publishing calculation rules is never a contributor proposal. The + // action has its own dedicated permission and confirmation contract. + if (module === 'calculations' && action === 'publishRevision') + return resolve(event); + const user = sessionUser; + const access = await shouldModerateDataMutation(user); + if (!access.canManage && access.canPropose && user) { + if (!access.contributorEnabled) { + if ( + event.request.headers.get('accept')?.includes('application/json') + ) { + return json( + { + type: 'failure', + status: 403, + data: { contributorModeRequired: true }, + }, + { status: 403 }, + ); + } + return new Response( + 'Contributor mode must be enabled before submitting data changes.', + { status: 403 }, + ); + } + const submittedForm = await event.request.formData(); + const proposal = await queueDataProposal( + user, + module, + action, + submittedForm, + ); + if (event.request.headers.get('accept')?.includes('application/json')) { + const result = proposalActionResult( + module, + action, + Boolean(proposal.entityId), + proposal.id, + ); + if (module === 'attributes' && action === 'save') { + Object.assign(result, { + savedItem: { + id: proposal.entityId ?? proposal.draftEntityId, + name: String(submittedForm.get('name') ?? ''), + type: String(submittedForm.get('type') ?? ''), + category: String(submittedForm.get('category') ?? ''), + calculation_type: String( + submittedForm.get('calculationType') ?? '', + ), + unit: String(submittedForm.get('unit') ?? ''), + active: 1, + pending: true, + proposalId: proposal.id, + proposerName: user.displayName, + }, + }); + } + return json({ + type: 'success', + status: 200, + data: stringify(result), + }); + } + const target = new URL(event.url.pathname, event.url); + target.searchParams.set('proposal', proposal.id); + return new Response(null, { + status: 303, + headers: { location: `${target.pathname}${target.search}` }, + }); + } + } + } + return resolve(event); }; -export const handleError: HandleServerError = ({ error, event, status, message }) => { - const errorId = randomUUID().slice(0, 8).toUpperCase(); - console.error(`[${errorId}] ${event.request.method} ${event.url.pathname} -> ${status}`, error); - return { - message: status >= 500 ? 'An unexpected fault interrupted this operation. No further action was applied.' : message, - errorId - }; +export const handleError: HandleServerError = ({ + error, + event, + status, + message, +}) => { + const errorId = randomUUID().slice(0, 8).toUpperCase(); + console.error( + `[${errorId}] ${event.request.method} ${event.url.pathname} -> ${status}`, + error, + ); + return { + message: + status >= 500 + ? 'An unexpected fault interrupted this operation. No further action was applied.' + : message, + errorId, + }; }; diff --git a/src/lib/agents.test.ts b/src/lib/agents.test.ts index 5ee6c47..ee1a148 100644 --- a/src/lib/agents.test.ts +++ b/src/lib/agents.test.ts @@ -1,50 +1,65 @@ import { describe, expect, it } from 'vitest'; import { - agentProfileHref, - displayNameKey, - newAgentDisplayName, - publicProfileSlugBase + agentProfileHref, + displayNameKey, + newAgentDisplayName, + publicProfileSlugBase, } from './agents.js'; describe('display names', () => { - it('uses one normalized key for uniqueness checks', () => { - expect(displayNameKey('Aewyn')).toBe(displayNameKey('Aewyn')); - expect(displayNameKey('E\u0301lite')).toBe(displayNameKey('Élite')); - }); + it('uses one normalized key for uniqueness checks', () => { + expect(displayNameKey('Aewyn')).toBe(displayNameKey('Aewyn')); + expect(displayNameKey('E\u0301lite')).toBe(displayNameKey('Élite')); + }); - it('creates readable short-UID placeholders', () => { - expect(newAgentDisplayName(47)).toBe('Agent 47'); - }); + it('creates readable short-UID placeholders', () => { + expect(newAgentDisplayName(47)).toBe('Agent 47'); + }); }); describe('publicProfileSlugBase', () => { - it('creates readable normalized slugs', () => { - expect(publicProfileSlugBase(' Aewyn Prime ', '12345678-0000-4000-8000-000000000000')) - .toBe('aewyn-prime'); - expect(publicProfileSlugBase('Élite 装備', '12345678-0000-4000-8000-000000000000')) - .toBe('élite-装備'); - }); + it('creates readable normalized slugs', () => { + expect( + publicProfileSlugBase( + ' Aewyn Prime ', + '12345678-0000-4000-8000-000000000000', + ), + ).toBe('aewyn-prime'); + expect( + publicProfileSlugBase( + 'Élite 装備', + '12345678-0000-4000-8000-000000000000', + ), + ).toBe('élite-装備'); + }); - it('uses a stable fallback when a name has no letters or numbers', () => { - expect(publicProfileSlugBase('⚡️', '12345678-0000-4000-8000-000000000000')) - .toBe('agent-12345678'); - }); + it('uses a stable fallback when a name has no letters or numbers', () => { + expect( + publicProfileSlugBase('⚡️', '12345678-0000-4000-8000-000000000000'), + ).toBe('agent-12345678'); + }); - it('keeps UUID-shaped display names out of the legacy UUID namespace', () => { - expect(publicProfileSlugBase( - '57a1160e-632f-4695-927f-7df03e20753f', - '12345678-0000-4000-8000-000000000000' - )).toBe('agent-57a1160e-632f-4695-927f-7df03e20753f'); - }); + it('keeps UUID-shaped display names out of the legacy UUID namespace', () => { + expect( + publicProfileSlugBase( + '57a1160e-632f-4695-927f-7df03e20753f', + '12345678-0000-4000-8000-000000000000', + ), + ).toBe('agent-57a1160e-632f-4695-927f-7df03e20753f'); + }); }); describe('agentProfileHref', () => { - it('prefers the public slug and safely encodes it', () => { - expect(agentProfileHref({ id: 'uuid', publicSlug: 'élite 装備' }, 'profile-comments')) - .toBe('/agents/%C3%A9lite%20%E8%A3%85%E5%82%99#profile-comments'); - }); + it('prefers the public slug and safely encodes it', () => { + expect( + agentProfileHref( + { id: 'uuid', publicSlug: 'élite 装備' }, + 'profile-comments', + ), + ).toBe('/agents/%C3%A9lite%20%E8%A3%85%E5%82%99#profile-comments'); + }); - it('falls back to the UUID for unmigrated records', () => { - expect(agentProfileHref({ id: 'uuid' })).toBe('/agents/uuid'); - }); + it('falls back to the UUID for unmigrated records', () => { + expect(agentProfileHref({ id: 'uuid' })).toBe('/agents/uuid'); + }); }); diff --git a/src/lib/agents.ts b/src/lib/agents.ts index f460dec..573c0a2 100644 --- a/src/lib/agents.ts +++ b/src/lib/agents.ts @@ -1,36 +1,40 @@ export type AgentProfileReference = { - id: string; - publicSlug?: string | null; + id: string; + publicSlug?: string | null; }; -const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; export function displayNameKey(displayName: string): string { - return displayName.normalize('NFKC').toLocaleLowerCase('en-US'); + return displayName.normalize('NFKC').toLocaleLowerCase('en-US'); } export function newAgentDisplayName(sequence: number): string { - return `Agent ${Math.max(1, Math.trunc(sequence))}`; + return `Agent ${Math.max(1, Math.trunc(sequence))}`; } -export function publicProfileSlugBase(displayName: string, userId: string): string { - const normalized = displayName - .normalize('NFKC') - .toLocaleLowerCase('en-US') - .replace(/[^\p{L}\p{N}\p{M}]+/gu, '-') - .replace(/^-+|-+$/g, '') - .slice(0, 64) - .replace(/-+$/g, ''); - const fallback = `agent-${userId.replaceAll('-', '').slice(0, 8).toLowerCase()}`; - if (!/[\p{L}\p{N}]/u.test(normalized)) return fallback; - return UUID_PATTERN.test(normalized) ? `agent-${normalized}` : normalized; +export function publicProfileSlugBase( + displayName: string, + userId: string, +): string { + const normalized = displayName + .normalize('NFKC') + .toLocaleLowerCase('en-US') + .replace(/[^\p{L}\p{N}\p{M}]+/gu, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 64) + .replace(/-+$/g, ''); + const fallback = `agent-${userId.replaceAll('-', '').slice(0, 8).toLowerCase()}`; + if (!/[\p{L}\p{N}]/u.test(normalized)) return fallback; + return UUID_PATTERN.test(normalized) ? `agent-${normalized}` : normalized; } export function agentProfileHref( - agent: AgentProfileReference, - fragment = '' + agent: AgentProfileReference, + fragment = '', ): string { - const identifier = agent.publicSlug || agent.id; - const suffix = fragment ? `#${fragment.replace(/^#/, '')}` : ''; - return `/agents/${encodeURIComponent(identifier)}${suffix}`; + const identifier = agent.publicSlug || agent.id; + const suffix = fragment ? `#${fragment.replace(/^#/, '')}` : ''; + return `/agents/${encodeURIComponent(identifier)}${suffix}`; } diff --git a/src/lib/ai/calculation-authoring.ts b/src/lib/ai/calculation-authoring.ts index dda80ef..08a810b 100644 --- a/src/lib/ai/calculation-authoring.ts +++ b/src/lib/ai/calculation-authoring.ts @@ -1,120 +1,120 @@ import type { - AiMechanicQuestion, - AiMechanicResponseMeta + AiMechanicQuestion, + AiMechanicResponseMeta, } from './mechanic-authoring'; export const AI_CALCULATION_STAGE_OPERATORS = [ - 'base_damage', - 'flat_add', - 'additive_percent', - 'multiplicative_percent', - 'expected_critical', - 'expected_headshot', - 'rate_of_fire', - 'magazine_size', - 'reload_speed', - 'firing_cycle' + 'base_damage', + 'flat_add', + 'additive_percent', + 'multiplicative_percent', + 'expected_critical', + 'expected_headshot', + 'rate_of_fire', + 'magazine_size', + 'reload_speed', + 'firing_cycle', ] as const; export const AI_SKILL_CALCULATION_STAGES = [ - 'engineering', - 'flat_damage', - 'skill_damage', - 'final_damage', - 'critical_chance', - 'critical_damage', - 'multishot_chance', - 'multishot_damage', - 'cooldown_recovery', - 'duration' + 'engineering', + 'flat_damage', + 'skill_damage', + 'final_damage', + 'critical_chance', + 'critical_damage', + 'multishot_chance', + 'multishot_damage', + 'cooldown_recovery', + 'duration', ] as const; export type AiCalculationStageDraft = { - stageKey: string; - label: string; - operator: (typeof AI_CALCULATION_STAGE_OPERATORS)[number]; - displayOrder: number; - lockedPosition: 'first' | 'last' | null; + stageKey: string; + label: string; + operator: (typeof AI_CALCULATION_STAGE_OPERATORS)[number]; + displayOrder: number; + lockedPosition: 'first' | 'last' | null; }; export type AiCalculationProfileDraft = { - statAttributeId: string; - stageKey: string; - weight: number; - softCap: number | null; - hardCap: number | null; - curveType: 'none' | 'power' | 'smoothstep'; - curveParameter: number | null; - weaponCategory: string; + statAttributeId: string; + stageKey: string; + weight: number; + softCap: number | null; + hardCap: number | null; + curveType: 'none' | 'power' | 'smoothstep'; + curveParameter: number | null; + weaponCategory: string; }; export type AiSkillCalculationProfileDraft = { - statAttributeId: string; - stageKey: (typeof AI_SKILL_CALCULATION_STAGES)[number]; - operator: 'add' | 'multiply'; - weight: number; + statAttributeId: string; + stageKey: (typeof AI_SKILL_CALCULATION_STAGES)[number]; + operator: 'add' | 'multiply'; + weight: number; }; export type AiCalculationRevisionDraft = { - name: string; - notes: string; - stages: AiCalculationStageDraft[]; - profiles: AiCalculationProfileDraft[]; - skillProfiles: AiSkillCalculationProfileDraft[]; + name: string; + notes: string; + stages: AiCalculationStageDraft[]; + profiles: AiCalculationProfileDraft[]; + skillProfiles: AiSkillCalculationProfileDraft[]; }; export type AiCalculationTranscriptEntry = { - questionId: string; - question: string; - answer: string; + questionId: string; + question: string; + answer: string; }; export type AiCalculationAuthoringRequest = { - instructions: string; - baseRevisionId: string | null; - currentDraft: AiCalculationRevisionDraft; - transcript: AiCalculationTranscriptEntry[]; + instructions: string; + baseRevisionId: string | null; + currentDraft: AiCalculationRevisionDraft; + transcript: AiCalculationTranscriptEntry[]; }; export type AiCalculationQuestion = AiMechanicQuestion; export type AiCalculationCapabilityGap = { - area: 'stage_operator' | 'curve' | 'skill_stage' | 'attribute' | 'runtime'; - requiredBehavior: string; - whyUnsupported: string; - developerChange: string; - workaround: string | null; + area: 'stage_operator' | 'curve' | 'skill_stage' | 'attribute' | 'runtime'; + requiredBehavior: string; + whyUnsupported: string; + developerChange: string; + workaround: string | null; }; export type AiCalculationClarificationResponse = { - status: 'clarification_required'; - summary: string; - assumptions: string[]; - warnings: string[]; - questions: AiCalculationQuestion[]; - meta: AiMechanicResponseMeta; + status: 'clarification_required'; + summary: string; + assumptions: string[]; + warnings: string[]; + questions: AiCalculationQuestion[]; + meta: AiMechanicResponseMeta; }; export type AiCalculationDraftResponse = { - status: 'draft_ready'; - draft: AiCalculationRevisionDraft; - summary: string; - assumptions: string[]; - warnings: string[]; - pseudoScript: string; - meta: AiMechanicResponseMeta; + status: 'draft_ready'; + draft: AiCalculationRevisionDraft; + summary: string; + assumptions: string[]; + warnings: string[]; + pseudoScript: string; + meta: AiMechanicResponseMeta; }; export type AiCalculationCapabilityGapResponse = { - status: 'capability_gap'; - summary: string; - assumptions: string[]; - warnings: string[]; - gaps: AiCalculationCapabilityGap[]; - meta: AiMechanicResponseMeta; + status: 'capability_gap'; + summary: string; + assumptions: string[]; + warnings: string[]; + gaps: AiCalculationCapabilityGap[]; + meta: AiMechanicResponseMeta; }; export type AiCalculationAuthoringResponse = - | AiCalculationClarificationResponse - | AiCalculationDraftResponse - | AiCalculationCapabilityGapResponse; + | AiCalculationClarificationResponse + | AiCalculationDraftResponse + | AiCalculationCapabilityGapResponse; diff --git a/src/lib/ai/mechanic-authoring.ts b/src/lib/ai/mechanic-authoring.ts index 03d5413..e64117a 100644 --- a/src/lib/ai/mechanic-authoring.ts +++ b/src/lib/ai/mechanic-authoring.ts @@ -1,140 +1,143 @@ import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor'; export const AI_MECHANIC_SOURCE_KINDS = [ - 'talent', - 'exotic_weapon_talent', - 'os_protocol_talent', - 'skill_chip_talent', - 'specialization_tree_rank' + 'talent', + 'exotic_weapon_talent', + 'os_protocol_talent', + 'skill_chip_talent', + 'specialization_tree_rank', ] as const; export type AiMechanicSourceKind = (typeof AI_MECHANIC_SOURCE_KINDS)[number]; export const AI_MECHANIC_SOURCE_PROFILES = { - talent: { - variantKeys: ['I', 'II'], - allowAssignedAbility: false, - allowProtocolUpgradeScaling: false - }, - exotic_weapon_talent: { - variantKeys: ['I', 'II'], - allowAssignedAbility: false, - allowProtocolUpgradeScaling: false - }, - os_protocol_talent: { - variantKeys: ['base'], - allowAssignedAbility: false, - allowProtocolUpgradeScaling: true - }, - skill_chip_talent: { - variantKeys: ['minimum', 'maximum'], - allowAssignedAbility: true, - allowProtocolUpgradeScaling: false - }, - specialization_tree_rank: { - variantKeys: ['base'], - allowAssignedAbility: false, - allowProtocolUpgradeScaling: false - } -} as const satisfies Record; + talent: { + variantKeys: ['I', 'II'], + allowAssignedAbility: false, + allowProtocolUpgradeScaling: false, + }, + exotic_weapon_talent: { + variantKeys: ['I', 'II'], + allowAssignedAbility: false, + allowProtocolUpgradeScaling: false, + }, + os_protocol_talent: { + variantKeys: ['base'], + allowAssignedAbility: false, + allowProtocolUpgradeScaling: true, + }, + skill_chip_talent: { + variantKeys: ['minimum', 'maximum'], + allowAssignedAbility: true, + allowProtocolUpgradeScaling: false, + }, + specialization_tree_rank: { + variantKeys: ['base'], + allowAssignedAbility: false, + allowProtocolUpgradeScaling: false, + }, +} as const satisfies Record< + AiMechanicSourceKind, + { + variantKeys: readonly string[]; + allowAssignedAbility: boolean; + allowProtocolUpgradeScaling: boolean; + } +>; export type AiMechanicContext = { - sourceKind: AiMechanicSourceKind; - sourceName: string; - sourceDescription: string; + sourceKind: AiMechanicSourceKind; + sourceName: string; + sourceDescription: string; }; export type AiMechanicValueColumn = { - key: string; - variantKey: string; - label: string; + key: string; + variantKey: string; + label: string; }; export type AiMechanicTranscriptEntry = { - questionId: string; - question: string; - answer: string | number | boolean; + questionId: string; + question: string; + answer: string | number | boolean; }; export type AiMechanicAuthoringRequest = { - context: AiMechanicContext; - currentDraft: ConditionalMechanicDraftV1; - valueColumns: AiMechanicValueColumn[]; - allowAssignedAbility: boolean; - allowProtocolUpgradeScaling: boolean; - transcript: AiMechanicTranscriptEntry[]; + context: AiMechanicContext; + currentDraft: ConditionalMechanicDraftV1; + valueColumns: AiMechanicValueColumn[]; + allowAssignedAbility: boolean; + allowProtocolUpgradeScaling: boolean; + transcript: AiMechanicTranscriptEntry[]; }; export type AiMechanicQuestion = { - id: string; - question: string; - reason: string; - answerType: 'text' | 'number' | 'choice' | 'boolean'; - options: string[]; + id: string; + question: string; + reason: string; + answerType: 'text' | 'number' | 'choice' | 'boolean'; + options: string[]; }; export type AiMechanicResponseMeta = { - requestId: string; - model: string; - inputTokens: number; - outputTokens: number; - totalTokens: number; + requestId: string; + model: string; + inputTokens: number; + outputTokens: number; + totalTokens: number; }; export type AiMechanicClarificationResponse = { - status: 'clarification_required'; - summary: string; - assumptions: string[]; - warnings: string[]; - questions: AiMechanicQuestion[]; - meta: AiMechanicResponseMeta; + status: 'clarification_required'; + summary: string; + assumptions: string[]; + warnings: string[]; + questions: AiMechanicQuestion[]; + meta: AiMechanicResponseMeta; }; export type AiMechanicDraftResponse = { - status: 'draft_ready'; - draft: ConditionalMechanicDraftV1; - summary: string; - assumptions: string[]; - warnings: string[]; - pseudoScript: string; - meta: AiMechanicResponseMeta; + status: 'draft_ready'; + draft: ConditionalMechanicDraftV1; + summary: string; + assumptions: string[]; + warnings: string[]; + pseudoScript: string; + meta: AiMechanicResponseMeta; }; export type AiMechanicCapabilityGap = { - area: - | 'event' - | 'predicate' - | 'reducer' - | 'mutation' - | 'outcome' - | 'scope' - | 'value_reference' - | 'source_variant' - | 'runtime'; - requiredBehavior: string; - whyUnsupported: string; - developerChange: string; - workaround: string | null; + area: + | 'event' + | 'predicate' + | 'reducer' + | 'mutation' + | 'outcome' + | 'scope' + | 'value_reference' + | 'source_variant' + | 'runtime'; + requiredBehavior: string; + whyUnsupported: string; + developerChange: string; + workaround: string | null; }; export type AiMechanicCapabilityGapResponse = { - status: 'capability_gap'; - summary: string; - assumptions: string[]; - warnings: string[]; - gaps: AiMechanicCapabilityGap[]; - meta: AiMechanicResponseMeta; + status: 'capability_gap'; + summary: string; + assumptions: string[]; + warnings: string[]; + gaps: AiMechanicCapabilityGap[]; + meta: AiMechanicResponseMeta; }; export type AiMechanicAuthoringResponse = - | AiMechanicClarificationResponse - | AiMechanicDraftResponse - | AiMechanicCapabilityGapResponse; + | AiMechanicClarificationResponse + | AiMechanicDraftResponse + | AiMechanicCapabilityGapResponse; export type AiMechanicAuthoringError = { - message: string; + message: string; }; diff --git a/src/lib/ai/mechanic-developer-handoff.test.ts b/src/lib/ai/mechanic-developer-handoff.test.ts index 48e2503..87a637a 100644 --- a/src/lib/ai/mechanic-developer-handoff.test.ts +++ b/src/lib/ai/mechanic-developer-handoff.test.ts @@ -1,66 +1,74 @@ import { describe, expect, it } from 'vitest'; -import { buildMechanicDeveloperPrompt, type AiMechanicDeveloperHandoff } from './mechanic-developer-handoff'; +import { + buildMechanicDeveloperPrompt, + type AiMechanicDeveloperHandoff, +} from './mechanic-developer-handoff'; const handoff: AiMechanicDeveloperHandoff = { - context: { - sourceKind: 'talent', - sourceName: 'Chain Reaction', - sourceDescription: 'Every third critical hit transfers the mark to a nearby enemy.' - }, - valueColumns: [ - { key: 'tierI', variantKey: 'I', label: 'TIER I' }, - { key: 'tierII', variantKey: 'II', label: 'TIER II' } - ], - allowAssignedAbility: false, - allowProtocolUpgradeScaling: false, - transcript: [{ - questionId: 'transfer-target', - question: 'How is the next target selected?', - answer: 'Nearest enemy' - }], - response: { - status: 'capability_gap', - summary: 'The runtime cannot select a nearby transfer target.', - assumptions: ['The third hit consumes the current mark.'], - warnings: ['Target selection must be deterministic.'], - gaps: [{ - area: 'runtime', - requiredBehavior: 'Select the nearest eligible enemy.', - whyUnsupported: 'The event has no ordered nearby-target collection.', - developerChange: 'Add a deterministic nearby-target selector.', - workaround: null - }], - meta: { - requestId: 'req_gap_123', - model: 'test-model', - inputTokens: 100, - outputTokens: 50, - totalTokens: 150 - } - } + context: { + sourceKind: 'talent', + sourceName: 'Chain Reaction', + sourceDescription: + 'Every third critical hit transfers the mark to a nearby enemy.', + }, + valueColumns: [ + { key: 'tierI', variantKey: 'I', label: 'TIER I' }, + { key: 'tierII', variantKey: 'II', label: 'TIER II' }, + ], + allowAssignedAbility: false, + allowProtocolUpgradeScaling: false, + transcript: [ + { + questionId: 'transfer-target', + question: 'How is the next target selected?', + answer: 'Nearest enemy', + }, + ], + response: { + status: 'capability_gap', + summary: 'The runtime cannot select a nearby transfer target.', + assumptions: ['The third hit consumes the current mark.'], + warnings: ['Target selection must be deterministic.'], + gaps: [ + { + area: 'runtime', + requiredBehavior: 'Select the nearest eligible enemy.', + whyUnsupported: 'The event has no ordered nearby-target collection.', + developerChange: 'Add a deterministic nearby-target selector.', + workaround: null, + }, + ], + meta: { + requestId: 'req_gap_123', + model: 'test-model', + inputTokens: 100, + outputTokens: 50, + totalTokens: 150, + }, + }, }; describe('mechanic developer handoff prompt', () => { - it('includes the exact analyzed source, clarification evidence, and required code change', () => { - const prompt = buildMechanicDeveloperPrompt(handoff); + it('includes the exact analyzed source, clarification evidence, and required code change', () => { + const prompt = buildMechanicDeveloperPrompt(handoff); - expect(prompt).toContain(handoff.context.sourceDescription); - expect(prompt).toContain('How is the next target selected?'); - expect(prompt).toContain('Answer: Nearest enemy'); - expect(prompt).toContain('Add a deterministic nearby-target selector.'); - expect(prompt).toContain('Assistant request ID: req_gap_123'); - expect(prompt).toContain('Persistence round-trips the new representation'); - }); + expect(prompt).toContain(handoff.context.sourceDescription); + expect(prompt).toContain('How is the next target selected?'); + expect(prompt).toContain('Answer: Nearest enemy'); + expect(prompt).toContain('Add a deterministic nearby-target selector.'); + expect(prompt).toContain('Assistant request ID: req_gap_123'); + expect(prompt).toContain('Persistence round-trips the new representation'); + }); - it('omits empty optional sections', () => { - const prompt = buildMechanicDeveloperPrompt({ - ...handoff, - transcript: [], - response: { ...handoff.response, assumptions: [], warnings: [] } - }); + it('omits empty optional sections', () => { + const prompt = buildMechanicDeveloperPrompt({ + ...handoff, + transcript: [], + response: { ...handoff.response, assumptions: [], warnings: [] }, + }); - expect(prompt).not.toContain('## Clarification history'); - expect(prompt).not.toContain('## Assumptions'); - expect(prompt).not.toContain('## Warnings'); - }); + expect(prompt).not.toContain('## Clarification history'); + expect(prompt).not.toContain('## Assumptions'); + expect(prompt).not.toContain('## Warnings'); + }); }); diff --git a/src/lib/ai/mechanic-developer-handoff.ts b/src/lib/ai/mechanic-developer-handoff.ts index 4bfe502..53b3482 100644 --- a/src/lib/ai/mechanic-developer-handoff.ts +++ b/src/lib/ai/mechanic-developer-handoff.ts @@ -1,40 +1,55 @@ import type { - AiMechanicCapabilityGapResponse, - AiMechanicContext, - AiMechanicTranscriptEntry, - AiMechanicValueColumn + AiMechanicCapabilityGapResponse, + AiMechanicContext, + AiMechanicTranscriptEntry, + AiMechanicValueColumn, } from '$lib/ai/mechanic-authoring'; export type AiMechanicDeveloperHandoff = { - context: AiMechanicContext; - valueColumns: AiMechanicValueColumn[]; - allowAssignedAbility: boolean; - allowProtocolUpgradeScaling: boolean; - transcript: AiMechanicTranscriptEntry[]; - response: AiMechanicCapabilityGapResponse; + context: AiMechanicContext; + valueColumns: AiMechanicValueColumn[]; + allowAssignedAbility: boolean; + allowProtocolUpgradeScaling: boolean; + transcript: AiMechanicTranscriptEntry[]; + response: AiMechanicCapabilityGapResponse; }; function section(title: string, items: string[]) { - return items.length ? `\n## ${title}\n\n${items.join('\n')}` : ''; + return items.length ? `\n## ${title}\n\n${items.join('\n')}` : ''; } -export function buildMechanicDeveloperPrompt(handoff: AiMechanicDeveloperHandoff) { - const { context, valueColumns, allowAssignedAbility, allowProtocolUpgradeScaling, transcript, response } = handoff; - const variants = valueColumns.map((column) => `${column.label} (${column.variantKey})`).join(', '); - const clarifications = transcript.map((entry) => - `- ${entry.question}\n Answer: ${String(entry.answer)}` - ); - const assumptions = response.assumptions.map((value) => `- ${value}`); - const warnings = response.warnings.map((value) => `- ${value}`); - const gaps = response.gaps.map((gap, index) => [ - `### Gap ${index + 1}: ${gap.area}`, - `- Required behavior: ${gap.requiredBehavior}`, - `- Why unsupported: ${gap.whyUnsupported}`, - `- Developer change: ${gap.developerChange}`, - gap.workaround ? `- Current workaround: ${gap.workaround}` : '- Current workaround: None' - ].join('\n')); +export function buildMechanicDeveloperPrompt( + handoff: AiMechanicDeveloperHandoff, +) { + const { + context, + valueColumns, + allowAssignedAbility, + allowProtocolUpgradeScaling, + transcript, + response, + } = handoff; + const variants = valueColumns + .map((column) => `${column.label} (${column.variantKey})`) + .join(', '); + const clarifications = transcript.map( + (entry) => `- ${entry.question}\n Answer: ${String(entry.answer)}`, + ); + const assumptions = response.assumptions.map((value) => `- ${value}`); + const warnings = response.warnings.map((value) => `- ${value}`); + const gaps = response.gaps.map((gap, index) => + [ + `### Gap ${index + 1}: ${gap.area}`, + `- Required behavior: ${gap.requiredBehavior}`, + `- Why unsupported: ${gap.whyUnsupported}`, + `- Developer change: ${gap.developerChange}`, + gap.workaround + ? `- Current workaround: ${gap.workaround}` + : '- Current workaround: None', + ].join('\n'), + ); - return `Implement support for the following ISAC mechanic-language gap in the SHD.build repository. + return `Implement support for the following ISAC mechanic-language gap in the SHD.build repository. Start by tracing the existing typed mechanic grammar, persistence adapter, runtime, editor, AI authoring context, and tests. Extend the smallest reusable layer that can represent the behavior exactly. Do not invent combat behavior or weaken deterministic validation. Preserve manual mechanic authoring and existing saved game data. Update shared registries, types, validation, persistence, runtime evaluation, editor controls, AI prompt context, tests, and maintained architecture/design documentation wherever the implementation requires them. Follow the repository's audit, database-preservation, accessibility, and development-server rules. diff --git a/src/lib/articles/document.test.ts b/src/lib/articles/document.test.ts new file mode 100644 index 0000000..a9d407b --- /dev/null +++ b/src/lib/articles/document.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest'; +import { EMPTY_ARTICLE_DOCUMENT, parseArticleDocument } from './document'; + +describe('article documents', () => { + it('accepts formatted prose, multiple Builds, and article images', () => { + const parsed = parseArticleDocument({ + version: 1, + document: { + type: 'doc', + content: [ + { + type: 'heading', + attrs: { level: 2 }, + content: [ + { type: 'text', text: 'Opening', marks: [{ type: 'bold' }] }, + ], + }, + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Use this rotation.', + marks: [ + { type: 'link', attrs: { href: 'https://shd.build/guides' } }, + ], + }, + ], + }, + { + type: 'buildEmbed', + attrs: { + buildId: '11111111-1111-4111-8111-111111111111', + embedKey: 'build-11111111-1111-4111-8111-111111111111', + }, + }, + { + type: 'buildEmbed', + attrs: { + buildId: '22222222-2222-4222-8222-222222222222', + embedKey: 'build-22222222-2222-4222-8222-222222222222', + }, + }, + { + type: 'articleImage', + attrs: { + imageId: '33333333-3333-4333-8333-333333333333', + alt: 'A loadout screen', + caption: 'Final configuration', + }, + }, + ], + }, + }); + expect(parsed?.buildEmbeds).toHaveLength(2); + expect(parsed?.imageIds).toEqual(['33333333-3333-4333-8333-333333333333']); + expect(parsed?.plainText).toContain('Opening Use this rotation.'); + }); + + it('rejects unsafe links and unknown nodes', () => { + expect( + parseArticleDocument({ + version: 1, + document: { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'unsafe', + marks: [ + { type: 'link', attrs: { href: 'javascript:alert(1)' } }, + ], + }, + ], + }, + ], + }, + }), + ).toBeNull(); + expect( + parseArticleDocument({ + version: 1, + document: { + type: 'doc', + content: [{ type: 'iframe', attrs: { src: 'https://example.com' } }], + }, + }), + ).toBeNull(); + }); + + it('rejects duplicate embed keys and unknown envelope fields', () => { + const embed = { + type: 'buildEmbed', + attrs: { + buildId: '11111111-1111-4111-8111-111111111111', + embedKey: 'build-11111111-1111-4111-8111-111111111111', + }, + }; + expect( + parseArticleDocument({ + version: 1, + document: { type: 'doc', content: [embed, embed] }, + }), + ).toBeNull(); + expect( + parseArticleDocument({ ...EMPTY_ARTICLE_DOCUMENT, legacy: true }), + ).toBeNull(); + }); +}); diff --git a/src/lib/articles/document.ts b/src/lib/articles/document.ts new file mode 100644 index 0000000..0ade92c --- /dev/null +++ b/src/lib/articles/document.ts @@ -0,0 +1,280 @@ +export const ARTICLE_DOCUMENT_VERSION = 1 as const; +export const MAX_ARTICLE_DOCUMENT_BYTES = 240_000; +export const MAX_ARTICLE_TEXT_CHARACTERS = 60_000; +export const MAX_ARTICLE_NODES = 1_200; +export const MAX_ARTICLE_DEPTH = 8; + +const ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const EMBED_KEY_PATTERN = /^[a-z0-9][a-z0-9-]{7,63}$/; + +export type ArticleMark = { + type: 'bold' | 'italic' | 'code' | 'link'; + attrs?: { href: string }; +}; + +export type ArticleNode = { + type: string; + attrs?: Record; + content?: ArticleNode[]; + marks?: ArticleMark[]; + text?: string; +}; + +export type ArticleDocument = { + version: typeof ARTICLE_DOCUMENT_VERSION; + document: ArticleNode; +}; + +export type ArticleBuildEmbed = { + embedKey: string; + buildId: string; + ordinal: number; +}; + +export const EMPTY_ARTICLE_DOCUMENT: ArticleDocument = { + version: ARTICLE_DOCUMENT_VERSION, + document: { type: 'doc', content: [{ type: 'paragraph' }] }, +}; + +const isRecord = (value: unknown): value is Record => + Boolean(value) && typeof value === 'object' && !Array.isArray(value); + +const exactKeys = (value: Record, allowed: string[]) => + Object.keys(value).every((key) => allowed.includes(key)); + +const safeLink = (value: unknown) => { + if (typeof value !== 'string' || value.length > 2_000) return null; + try { + const url = new URL(value); + return ['http:', 'https:'].includes(url.protocol) ? url.toString() : null; + } catch { + return null; + } +}; + +function parseMarks(value: unknown): ArticleMark[] | null { + if (value === undefined) return []; + if (!Array.isArray(value) || value.length > 4) return null; + const seen = new Set(); + const marks: ArticleMark[] = []; + for (const candidate of value) { + if ( + !isRecord(candidate) || + typeof candidate.type !== 'string' || + seen.has(candidate.type) + ) + return null; + seen.add(candidate.type); + if (['bold', 'italic', 'code'].includes(candidate.type)) { + if (!exactKeys(candidate, ['type'])) return null; + marks.push({ type: candidate.type as 'bold' | 'italic' | 'code' }); + continue; + } + if ( + candidate.type !== 'link' || + !exactKeys(candidate, ['type', 'attrs']) || + !isRecord(candidate.attrs) + ) + return null; + if (!exactKeys(candidate.attrs, ['href'])) return null; + const href = safeLink(candidate.attrs.href); + if (!href) return null; + marks.push({ type: 'link', attrs: { href } }); + } + return marks; +} + +export function parseArticleDocument(value: unknown): { + document: ArticleDocument; + plainText: string; + buildEmbeds: ArticleBuildEmbed[]; + imageIds: string[]; + links: string[]; +} | null { + if (typeof value === 'string') { + if (new TextEncoder().encode(value).byteLength > MAX_ARTICLE_DOCUMENT_BYTES) + return null; + try { + value = JSON.parse(value); + } catch { + return null; + } + } + if ( + !isRecord(value) || + !exactKeys(value, ['version', 'document']) || + value.version !== ARTICLE_DOCUMENT_VERSION + ) + return null; + let nodeCount = 0; + let textLength = 0; + let embedOrdinal = 0; + const textParts: string[] = []; + const buildEmbeds: ArticleBuildEmbed[] = []; + const imageIds: string[] = []; + const embedKeys = new Set(); + + const parseNode = (candidate: unknown, depth: number): ArticleNode | null => { + if ( + !isRecord(candidate) || + depth > MAX_ARTICLE_DEPTH || + ++nodeCount > MAX_ARTICLE_NODES + ) + return null; + if (typeof candidate.type !== 'string') return null; + const type = candidate.type; + if (type === 'text') { + if ( + !exactKeys(candidate, ['type', 'text', 'marks']) || + typeof candidate.text !== 'string' + ) + return null; + const text = candidate.text.normalize('NFKC'); + if (/\p{Cc}/u.test(text.replace(/[\n\t]/g, ''))) return null; + textLength += [...text].length; + if (textLength > MAX_ARTICLE_TEXT_CHARACTERS) return null; + const marks = parseMarks(candidate.marks); + if (!marks) return null; + textParts.push(text); + return marks.length ? { type, text, marks } : { type, text }; + } + if (type === 'buildEmbed') { + if ( + !exactKeys(candidate, ['type', 'attrs']) || + !isRecord(candidate.attrs) || + !exactKeys(candidate.attrs, ['buildId', 'embedKey']) + ) + return null; + const buildId = String(candidate.attrs.buildId ?? ''); + const embedKey = String(candidate.attrs.embedKey ?? ''); + if ( + !ID_PATTERN.test(buildId) || + !EMBED_KEY_PATTERN.test(embedKey) || + embedKeys.has(embedKey) + ) + return null; + embedKeys.add(embedKey); + buildEmbeds.push({ buildId, embedKey, ordinal: embedOrdinal++ }); + textParts.push('[Embedded Build]'); + return { type, attrs: { buildId, embedKey } }; + } + if (type === 'articleImage') { + if ( + !exactKeys(candidate, ['type', 'attrs']) || + !isRecord(candidate.attrs) || + !exactKeys(candidate.attrs, ['imageId', 'alt', 'caption']) + ) + return null; + const imageId = String(candidate.attrs.imageId ?? ''); + const alt = String(candidate.attrs.alt ?? '') + .normalize('NFKC') + .trim(); + const caption = String(candidate.attrs.caption ?? '') + .normalize('NFKC') + .trim(); + if ( + !ID_PATTERN.test(imageId) || + !alt || + [...alt].length > 300 || + [...caption].length > 500 + ) + return null; + imageIds.push(imageId); + textParts.push(alt, caption); + return { type, attrs: { imageId, alt, caption } }; + } + const leafTypes = ['horizontalRule', 'hardBreak']; + if (leafTypes.includes(type)) { + return exactKeys(candidate, ['type']) ? { type } : null; + } + const containerTypes = [ + 'doc', + 'paragraph', + 'heading', + 'bulletList', + 'orderedList', + 'listItem', + 'blockquote', + ]; + if ( + !containerTypes.includes(type) || + !exactKeys(candidate, ['type', 'attrs', 'content']) + ) + return null; + let attrs: Record | undefined; + if (type === 'heading') { + if ( + !isRecord(candidate.attrs) || + !exactKeys(candidate.attrs, ['level']) || + ![2, 3].includes(Number(candidate.attrs.level)) + ) + return null; + attrs = { level: Number(candidate.attrs.level) }; + } else if (candidate.attrs !== undefined) { + if (!isRecord(candidate.attrs) || Object.keys(candidate.attrs).length) + return null; + } + if (candidate.content !== undefined && !Array.isArray(candidate.content)) + return null; + const content: ArticleNode[] = []; + for (const child of candidate.content ?? []) { + const parsed = parseNode(child, depth + 1); + if (!parsed) return null; + content.push(parsed); + } + if (['paragraph', 'heading', 'listItem', 'blockquote'].includes(type)) + textParts.push('\n'); + return { + type, + ...(attrs ? { attrs } : {}), + ...(content.length ? { content } : {}), + }; + }; + + const document = parseNode(value.document, 0); + if (!document || document.type !== 'doc') return null; + const parsed: ArticleDocument = { + version: ARTICLE_DOCUMENT_VERSION, + document, + }; + if ( + new TextEncoder().encode(JSON.stringify(parsed)).byteLength > + MAX_ARTICLE_DOCUMENT_BYTES + ) + return null; + const links: string[] = []; + const collectLinks = (node: ArticleNode) => { + for (const mark of node.marks ?? []) + if (mark.type === 'link' && mark.attrs?.href) links.push(mark.attrs.href); + for (const child of node.content ?? []) collectLinks(child); + }; + collectLinks(document); + return { + document: parsed, + plainText: textParts.join(' ').replace(/\s+/g, ' ').trim(), + buildEmbeds, + imageIds: [...new Set(imageIds)], + links: [...new Set(links)], + }; +} + +export function articleModerationText(input: { + title: string; + deck: string; + plainText: string; + coverAlt?: string; + coverCaption?: string; + links?: string[]; +}) { + return [ + input.title, + input.deck, + input.coverAlt, + input.coverCaption, + input.plainText, + ...(input.links ?? []), + ] + .filter(Boolean) + .join('\n\n'); +} diff --git a/src/lib/articles/editor-extensions.ts b/src/lib/articles/editor-extensions.ts new file mode 100644 index 0000000..90217d0 --- /dev/null +++ b/src/lib/articles/editor-extensions.ts @@ -0,0 +1,71 @@ +import { Node, mergeAttributes } from '@tiptap/core'; + +export const BuildEmbedNode = Node.create({ + name: 'buildEmbed', + group: 'block', + atom: true, + selectable: true, + draggable: true, + addAttributes() { + return { + buildId: { default: null }, + embedKey: { default: null }, + }; + }, + parseHTML() { + return [{ tag: 'article[data-build-embed]' }]; + }, + renderHTML({ HTMLAttributes }) { + return [ + 'article', + mergeAttributes(HTMLAttributes, { + 'data-build-embed': '', + class: 'article-editor-embed', + contenteditable: 'false', + }), + ['span', {}, 'EMBEDDED BUILD'], + ['strong', {}, String(HTMLAttributes.buildId ?? 'BUILD')], + ]; + }, +}); + +export const ArticleImageNode = Node.create({ + name: 'articleImage', + group: 'block', + atom: true, + selectable: true, + draggable: true, + addAttributes() { + return { + imageId: { default: null }, + alt: { default: '' }, + caption: { default: '' }, + }; + }, + parseHTML() { + return [{ tag: 'figure[data-article-image]' }]; + }, + renderHTML({ HTMLAttributes }) { + const imageId = String(HTMLAttributes.imageId ?? ''); + return [ + 'figure', + mergeAttributes(HTMLAttributes, { + 'data-article-image': '', + class: 'article-editor-image', + contenteditable: 'false', + }), + [ + 'img', + { + src: `/images/library/${encodeURIComponent(imageId)}`, + alt: String(HTMLAttributes.alt ?? ''), + }, + ], + [ + 'figcaption', + {}, + String(HTMLAttributes.caption || HTMLAttributes.alt || 'ARTICLE IMAGE'), + ], + ]; + }, +}); diff --git a/src/lib/avatar.ts b/src/lib/avatar.ts index 5ec08d0..4caede1 100644 --- a/src/lib/avatar.ts +++ b/src/lib/avatar.ts @@ -1,29 +1,89 @@ export const AVATAR_PRESETS = [ - { id: 'field-agent', label: 'Field Agent', url: '/avatars/default/field-agent.webp' }, - { id: 'field-medic', label: 'Field Medic', url: '/avatars/default/field-medic.webp' }, - { id: 'systems-engineer', label: 'Systems Engineer', url: '/avatars/default/systems-engineer.webp' }, - { id: 'recon-scout', label: 'Recon Scout', url: '/avatars/default/recon-scout.webp' }, - { id: 'drone-pilot', label: 'Drone Pilot', url: '/avatars/default/drone-pilot.webp' }, - { id: 'field-commander', label: 'Field Commander', url: '/avatars/default/field-commander.webp' }, - { id: 'rescue-specialist', label: 'Rescue Specialist', url: '/avatars/default/rescue-specialist.webp' }, - { id: 'equipment-engineer', label: 'Equipment Engineer', url: '/avatars/default/equipment-engineer.webp' }, - { id: 'wilderness-tracker', label: 'Wilderness Tracker', url: '/avatars/default/wilderness-tracker.webp' }, - { id: 'intelligence-analyst', label: 'Intelligence Analyst', url: '/avatars/default/intelligence-analyst.webp' }, - { id: 'combat-medic', label: 'Combat Medic', url: '/avatars/default/combat-medic.webp' }, - { id: 'comms-specialist', label: 'Comms Specialist', url: '/avatars/default/comms-specialist.webp' } + { + id: 'field-agent', + label: 'Field Agent', + url: '/avatars/default/field-agent.webp', + }, + { + id: 'field-medic', + label: 'Field Medic', + url: '/avatars/default/field-medic.webp', + }, + { + id: 'systems-engineer', + label: 'Systems Engineer', + url: '/avatars/default/systems-engineer.webp', + }, + { + id: 'recon-scout', + label: 'Recon Scout', + url: '/avatars/default/recon-scout.webp', + }, + { + id: 'drone-pilot', + label: 'Drone Pilot', + url: '/avatars/default/drone-pilot.webp', + }, + { + id: 'field-commander', + label: 'Field Commander', + url: '/avatars/default/field-commander.webp', + }, + { + id: 'rescue-specialist', + label: 'Rescue Specialist', + url: '/avatars/default/rescue-specialist.webp', + }, + { + id: 'equipment-engineer', + label: 'Equipment Engineer', + url: '/avatars/default/equipment-engineer.webp', + }, + { + id: 'wilderness-tracker', + label: 'Wilderness Tracker', + url: '/avatars/default/wilderness-tracker.webp', + }, + { + id: 'intelligence-analyst', + label: 'Intelligence Analyst', + url: '/avatars/default/intelligence-analyst.webp', + }, + { + id: 'combat-medic', + label: 'Combat Medic', + url: '/avatars/default/combat-medic.webp', + }, + { + id: 'comms-specialist', + label: 'Comms Specialist', + url: '/avatars/default/comms-specialist.webp', + }, ] as const; export const DEFAULT_AVATAR_PRESET = AVATAR_PRESETS[0].id; -export function isAvatarPreset(value: string): value is (typeof AVATAR_PRESETS)[number]['id'] { - return AVATAR_PRESETS.some((preset) => preset.id === value); +export function isAvatarPreset( + value: string, +): value is (typeof AVATAR_PRESETS)[number]['id'] { + return AVATAR_PRESETS.some((preset) => preset.id === value); } -export function avatarUrl(source: unknown, value: unknown, userId: string, updatedAt?: unknown): string { - if (source === 'upload') { - const version = updatedAt ? `?v=${encodeURIComponent(String(updatedAt))}` : ''; - return `/avatars/user/${encodeURIComponent(userId)}${version}`; - } - const preset = typeof value === 'string' && isAvatarPreset(value) ? value : DEFAULT_AVATAR_PRESET; - return `/avatars/default/${preset}.webp`; +export function avatarUrl( + source: unknown, + value: unknown, + userId: string, + updatedAt?: unknown, +): string { + if (source === 'upload') { + const version = updatedAt + ? `?v=${encodeURIComponent(String(updatedAt))}` + : ''; + return `/avatars/user/${encodeURIComponent(userId)}${version}`; + } + const preset = + typeof value === 'string' && isAvatarPreset(value) + ? value + : DEFAULT_AVATAR_PRESET; + return `/avatars/default/${preset}.webp`; } diff --git a/src/lib/builder/loadout-combat.test.ts b/src/lib/builder/loadout-combat.test.ts index cfab985..d8fa0f2 100644 --- a/src/lib/builder/loadout-combat.test.ts +++ b/src/lib/builder/loadout-combat.test.ts @@ -1,651 +1,737 @@ import { describe, expect, it } from 'vitest'; -import { evaluateMechanicProgram, type MechanicProgram } from '$lib/calculations/mechanic'; +import { + evaluateMechanicProgram, + type MechanicProgram, +} from '$lib/calculations/mechanic'; import { createDraft } from './loadout.js'; import { - buildSkillDeploymentEvents, - buildWeaponMechanicTimeline, - equippedConditionalProgram, - mechanicModifiersAt, - skillModifierWindowsForMechanic, - weaponCriticalChance + buildSkillDeploymentEvents, + buildWeaponMechanicTimeline, + equippedConditionalProgram, + mechanicModifiersAt, + skillModifierWindowsForMechanic, + weaponCriticalChance, } from './loadout-combat.js'; -import { calculateSkillDps, type SkillAbilityInput } from '$lib/calculations/skill'; +import { + calculateSkillDps, + type SkillAbilityInput, +} from '$lib/calculations/skill'; describe('loadout mechanic timeline', () => { - function magazineBandsFor(magazine: number, encounterSeconds = magazine - 1) { - const profile = createDraft().combatProfile; - profile.encounterSeconds = encounterSeconds; - profile.hitRate = 0; - profile.shotsPerBurst = 100; - profile.burstPauseSeconds = 0; + function magazineBandsFor(magazine: number, encounterSeconds = magazine - 1) { + const profile = createDraft().combatProfile; + profile.encounterSeconds = encounterSeconds; + profile.hitRate = 0; + profile.shotsPerBurst = 100; + profile.burstPauseSeconds = 0; - return buildWeaponMechanicTimeline( - { rpm: 60, magazine, reload_speed: 0 }, - profile - ) - .filter((event) => event.kind === 'shot_fired') - .map((event) => event.values?.magazineTwentyPercentBandsCrossed); - } + return buildWeaponMechanicTimeline( + { rpm: 60, magazine, reload_speed: 0 }, + profile, + ) + .filter((event) => event.kind === 'shot_fired') + .map((event) => event.values?.magazineTwentyPercentBandsCrossed); + } - it('emits deterministic critical hits from modeled critical chance', () => { - const profile = createDraft().combatProfile; - profile.encounterSeconds = 4; - profile.hitRate = 100; - profile.shotsPerBurst = 100; - profile.burstPauseSeconds = 0; + it('emits deterministic critical hits from modeled critical chance', () => { + const profile = createDraft().combatProfile; + profile.encounterSeconds = 4; + profile.hitRate = 100; + profile.shotsPerBurst = 100; + profile.burstPauseSeconds = 0; - const timeline = buildWeaponMechanicTimeline( - { rpm: 60, magazine: 100, reload_speed: 0 }, - profile, - 50 - ); - const criticalHits = timeline - .filter((event) => event.kind === 'weapon_hit') - .map((event) => event.values?.critical); + const timeline = buildWeaponMechanicTimeline( + { rpm: 60, magazine: 100, reload_speed: 0 }, + profile, + 50, + ); + const criticalHits = timeline + .filter((event) => event.kind === 'weapon_hit') + .map((event) => event.values?.critical); - expect(criticalHits).toEqual([false, true, false, true, false]); - }); + expect(criticalHits).toEqual([false, true, false, true, false]); + }); - it('marks every fired shot with its deterministic hit result', () => { - const profile = createDraft().combatProfile; - profile.encounterSeconds = 4; - profile.hitRate = 50; - profile.shotsPerBurst = 100; - profile.burstPauseSeconds = 0; + it('marks every fired shot with its deterministic hit result', () => { + const profile = createDraft().combatProfile; + profile.encounterSeconds = 4; + profile.hitRate = 50; + profile.shotsPerBurst = 100; + profile.burstPauseSeconds = 0; - const hitResults = buildWeaponMechanicTimeline( - { rpm: 60, magazine: 100, reload_speed: 0 }, - profile - ) - .filter((event) => event.kind === 'shot_fired') - .map((event) => event.values?.hit); + const hitResults = buildWeaponMechanicTimeline( + { rpm: 60, magazine: 100, reload_speed: 0 }, + profile, + ) + .filter((event) => event.kind === 'shot_fired') + .map((event) => event.values?.hit); - expect(hitResults).toEqual([false, true, false, true, false]); - }); + expect(hitResults).toEqual([false, true, false, true, false]); + }); - it('marks enemy kills from deterministic critical killing shots', () => { - const profile = createDraft().combatProfile; - profile.encounterSeconds = 30; - profile.killsPerMinute = 4; + it('marks enemy kills from deterministic critical killing shots', () => { + const profile = createDraft().combatProfile; + profile.encounterSeconds = 30; + profile.killsPerMinute = 4; - const criticalKills = buildWeaponMechanicTimeline( - { rpm: 60, magazine: 100, reload_speed: 0 }, - profile, - 50 - ) - .filter((event) => event.kind === 'enemy_killed') - .map((event) => event.values?.critical); + const criticalKills = buildWeaponMechanicTimeline( + { rpm: 60, magazine: 100, reload_speed: 0 }, + profile, + 50, + ) + .filter((event) => event.kind === 'enemy_killed') + .map((event) => event.values?.critical); - expect(criticalKills).toEqual([false, true]); - }); + expect(criticalKills).toEqual([false, true]); + }); - it('marks enemy kills from the configured headshot-kill rate', () => { - const profile = createDraft().combatProfile; - profile.encounterSeconds = 30; - profile.killsPerMinute = 8; - profile.headshotKillRate = 50; + it('marks enemy kills from the configured headshot-kill rate', () => { + const profile = createDraft().combatProfile; + profile.encounterSeconds = 30; + profile.killsPerMinute = 8; + profile.headshotKillRate = 50; - const headshotKills = buildWeaponMechanicTimeline( - { rpm: 60, magazine: 100, reload_speed: 0 }, - profile - ) - .filter((event) => event.kind === 'enemy_killed') - .map((event) => event.values?.headshot); + const headshotKills = buildWeaponMechanicTimeline( + { rpm: 60, magazine: 100, reload_speed: 0 }, + profile, + ) + .filter((event) => event.kind === 'enemy_killed') + .map((event) => event.values?.headshot); - expect(headshotKills).toEqual([false, true, false, true]); - }); + expect(headshotKills).toEqual([false, true, false, true]); + }); - it('marks reload starts from the pre-reload empty-magazine state', () => { - const profile = createDraft().combatProfile; - profile.encounterSeconds = 2; - profile.shotsPerBurst = 100; - profile.burstPauseSeconds = 0; + it('marks reload starts from the pre-reload empty-magazine state', () => { + const profile = createDraft().combatProfile; + profile.encounterSeconds = 2; + profile.shotsPerBurst = 100; + profile.burstPauseSeconds = 0; - const emptyStates = buildWeaponMechanicTimeline( - { rpm: 60, magazine: 2, reload_speed: 5 }, - profile - ) - .filter((event) => event.kind === 'reload_started') - .map((event) => event.values?.emptyMagazine); + const emptyStates = buildWeaponMechanicTimeline( + { rpm: 60, magazine: 2, reload_speed: 5 }, + profile, + ) + .filter((event) => event.kind === 'reload_started') + .map((event) => event.values?.emptyMagazine); - expect(emptyStates).toEqual([true]); - }); + expect(emptyStates).toEqual([true]); + }); - it('emits post-shot 20% magazine-band crossings for an even magazine', () => { - expect(magazineBandsFor(10)).toEqual([0, 1, 0, 1, 0, 1, 0, 1, 0, 0]); - }); + it('emits post-shot 20% magazine-band crossings for an even magazine', () => { + expect(magazineBandsFor(10)).toEqual([0, 1, 0, 1, 0, 1, 0, 1, 0, 0]); + }); - it.each([ - { magazine: 7, expected: [0, 1, 1, 0, 1, 1, 0] }, - { magazine: 3, expected: [1, 2, 1] } - ])('counts every crossed band for an odd/small $magazine-round magazine', ({ magazine, expected }) => { - expect(magazineBandsFor(magazine)).toEqual(expected); - }); + it.each([ + { magazine: 7, expected: [0, 1, 1, 0, 1, 1, 0] }, + { magazine: 3, expected: [1, 2, 1] }, + ])( + 'counts every crossed band for an odd/small $magazine-round magazine', + ({ magazine, expected }) => { + expect(magazineBandsFor(magazine)).toEqual(expected); + }, + ); - it('restarts magazine-band crossings after reloading', () => { - expect(magazineBandsFor(5, 5)).toEqual([1, 1, 1, 1, 0, 1]); - }); + it('restarts magazine-band crossings after reloading', () => { + expect(magazineBandsFor(5, 5)).toEqual([1, 1, 1, 1, 0, 1]); + }); - it('derives critical chance through the configured binding transform', () => { - expect(weaponCriticalChance( - { critical: 80 }, - [{ - attributeId: 'critical', - stage: 'critical_chance', - operator: 'add', - weight: 0.5, - hardCap: 30, - curveType: 'none' - }] - )).toBe(30); - }); + it('derives critical chance through the configured binding transform', () => { + expect( + weaponCriticalChance({ critical: 80 }, [ + { + attributeId: 'critical', + stage: 'critical_chance', + operator: 'add', + weight: 0.5, + hardCap: 30, + curveType: 'none', + }, + ]), + ).toBe(30); + }); - it('publishes one standardized movement state and lets sprinting win', () => { - const profile = createDraft().combatProfile; - profile.encounterSeconds = 1; + it('publishes one standardized movement state and lets sprinting win', () => { + const profile = createDraft().combatProfile; + profile.encounterSeconds = 1; - const moving = buildWeaponMechanicTimeline( - { rpm: 60, magazine: 100, reload_speed: 0 }, - profile, - 0, - { scenarioInputs: { moving: true, sprinting: false } } - ).filter((event) => event.kind === 'movement_snapshot'); - const sprinting = buildWeaponMechanicTimeline( - { rpm: 60, magazine: 100, reload_speed: 0 }, - profile, - 0, - { scenarioInputs: { moving: true, sprinting: true } } - ).filter((event) => event.kind === 'movement_snapshot'); + const moving = buildWeaponMechanicTimeline( + { rpm: 60, magazine: 100, reload_speed: 0 }, + profile, + 0, + { scenarioInputs: { moving: true, sprinting: false } }, + ).filter((event) => event.kind === 'movement_snapshot'); + const sprinting = buildWeaponMechanicTimeline( + { rpm: 60, magazine: 100, reload_speed: 0 }, + profile, + 0, + { scenarioInputs: { moving: true, sprinting: true } }, + ).filter((event) => event.kind === 'movement_snapshot'); - expect(moving.map((event) => event.values)).toEqual([ - { state: 'moving' }, - { state: 'moving' } - ]); - expect(sprinting.every((event) => event.values?.state === 'sprinting')).toBe(true); - }); + expect(moving.map((event) => event.values)).toEqual([ + { state: 'moving' }, + { state: 'moving' }, + ]); + expect( + sprinting.every((event) => event.values?.state === 'sprinting'), + ).toBe(true); + }); - it('seeds Extra Health state before periodic stack evaluation', () => { - const profile = createDraft().combatProfile; - profile.encounterSeconds = 2; - profile.extraHealthActive = true; - const timeline = buildWeaponMechanicTimeline( - { rpm: 60, magazine: 100, reload_speed: 0 }, - profile - ); - expect(timeline.filter((event) => event.kind === 'extra_health_changed')).toEqual([{ - kind: 'extra_health_changed', - at: 0, - actorId: 'agent', - values: { hasExtraHealth: true } - }]); + it('seeds Extra Health state before periodic stack evaluation', () => { + const profile = createDraft().combatProfile; + profile.encounterSeconds = 2; + profile.extraHealthActive = true; + const timeline = buildWeaponMechanicTimeline( + { rpm: 60, magazine: 100, reload_speed: 0 }, + profile, + ); + expect( + timeline.filter((event) => event.kind === 'extra_health_changed'), + ).toEqual([ + { + kind: 'extra_health_changed', + at: 0, + actorId: 'agent', + values: { hasExtraHealth: true }, + }, + ]); - const program: MechanicProgram = { - id: 'extra-health-periodic-stacks', - name: 'Extra Health periodic stacks', - pools: [ - { key: 'active', label: 'Active', scope: 'agent', initial: 0, min: 0, max: 1 }, - { key: 'stacks', label: 'Stacks', scope: 'agent', initial: 0, min: 0, max: 10 } - ], - clauses: [{ - id: 'set-active', - when: 'extra_health_changed', - if: { kind: 'compare', left: { source: 'event', path: 'values.hasExtraHealth' }, operator: 'eq', right: true }, - reducer: { kind: 'none' }, - mutations: [{ kind: 'set', pool: 'active', value: 1 }] - }, { - id: 'gain-stack', - when: 'tick', - if: { kind: 'compare', left: { source: 'pool', pool: 'active' }, operator: 'eq', right: 1 }, - reducer: { kind: 'periodic', intervalSeconds: 1 }, - mutations: [{ kind: 'add', pool: 'stacks', value: 1 }] - }] - }; - const result = evaluateMechanicProgram(program, timeline, { - mode: 'custom', - encounterSeconds: 2 - }); - expect(result.finalState).toEqual(expect.arrayContaining([ - expect.objectContaining({ pool: 'stacks', value: 2 }) - ])); - expect(result.emissions.filter((emission) => emission.clauseId === 'gain-stack') - .map((emission) => emission.at)).toEqual([1, 2]); - }); + const program: MechanicProgram = { + id: 'extra-health-periodic-stacks', + name: 'Extra Health periodic stacks', + pools: [ + { + key: 'active', + label: 'Active', + scope: 'agent', + initial: 0, + min: 0, + max: 1, + }, + { + key: 'stacks', + label: 'Stacks', + scope: 'agent', + initial: 0, + min: 0, + max: 10, + }, + ], + clauses: [ + { + id: 'set-active', + when: 'extra_health_changed', + if: { + kind: 'compare', + left: { source: 'event', path: 'values.hasExtraHealth' }, + operator: 'eq', + right: true, + }, + reducer: { kind: 'none' }, + mutations: [{ kind: 'set', pool: 'active', value: 1 }], + }, + { + id: 'gain-stack', + when: 'tick', + if: { + kind: 'compare', + left: { source: 'pool', pool: 'active' }, + operator: 'eq', + right: 1, + }, + reducer: { kind: 'periodic', intervalSeconds: 1 }, + mutations: [{ kind: 'add', pool: 'stacks', value: 1 }], + }, + ], + }; + const result = evaluateMechanicProgram(program, timeline, { + mode: 'custom', + encounterSeconds: 2, + }); + expect(result.finalState).toEqual( + expect.arrayContaining([ + expect.objectContaining({ pool: 'stacks', value: 2 }), + ]), + ); + expect( + result.emissions + .filter((emission) => emission.clauseId === 'gain-stack') + .map((emission) => emission.at), + ).toEqual([1, 2]); + }); - it('falls back to a documented Tier I mechanic when equipment Tier II is absent', () => { - const persistedRule = { - rule: { - id: 'tier-i-only', - source_type: 'talent', - source_id: 'exotic-talent', - configured: 1, - script_version: 1, - simulation_readiness: 'ready', - scenario_inputs_json: '[]' - }, - statePools: [], - clauses: [{ - id: 'shot-clause', - rule_id: 'tier-i-only', - clause_key: 'shot', - label: 'Shot', - trigger_id: null, - event_kind: 'shot_fired', - parameters_json: '{}', - conditions_json: JSON.stringify({ kind: 'true' }), - reducer_json: JSON.stringify({ kind: 'none' }), - emission_kind: 'each_match', - state_pool_id: null, - mutations_json: '[]', - cooldown_seconds: null, - output_branch: 'active', - sort_order: 1, - active: 1 - }], - outcomes: [{ - id: 'weapon-damage-i', - rule_id: 'tier-i-only', - clause_id: 'shot-clause', - state_pool_id: null, - effect_key: 'Weapon Damage', - variant_key: 'I', - outcome_key: 'weapon-damage', - application_kind: 'active_modifier', - effect_scope: 'equipped_weapon', - stat_attribute_id: 'weapon-damage', - value: 15, - value_expression_json: null, - value_basis: 'flat', - resource_kind: null, - combat_timing: null, - delivery_mode: 'instant', - delivery_duration_seconds: null, - targeting_json: null, - value_scaling: 'flat', - unit: '%', - damage_basis: null, - application_phase: 'on_event', - min_state_value: null, - max_state_value: null, - description_slot: 1, - sort_order: 1 - }], - triggers: [] - }; + it('falls back to a documented Tier I mechanic when equipment Tier II is absent', () => { + const persistedRule = { + rule: { + id: 'tier-i-only', + source_type: 'talent', + source_id: 'exotic-talent', + configured: 1, + script_version: 1, + simulation_readiness: 'ready', + scenario_inputs_json: '[]', + }, + statePools: [], + clauses: [ + { + id: 'shot-clause', + rule_id: 'tier-i-only', + clause_key: 'shot', + label: 'Shot', + trigger_id: null, + event_kind: 'shot_fired', + parameters_json: '{}', + conditions_json: JSON.stringify({ kind: 'true' }), + reducer_json: JSON.stringify({ kind: 'none' }), + emission_kind: 'each_match', + state_pool_id: null, + mutations_json: '[]', + cooldown_seconds: null, + output_branch: 'active', + sort_order: 1, + active: 1, + }, + ], + outcomes: [ + { + id: 'weapon-damage-i', + rule_id: 'tier-i-only', + clause_id: 'shot-clause', + state_pool_id: null, + effect_key: 'Weapon Damage', + variant_key: 'I', + outcome_key: 'weapon-damage', + application_kind: 'active_modifier', + effect_scope: 'equipped_weapon', + stat_attribute_id: 'weapon-damage', + value: 15, + value_expression_json: null, + value_basis: 'flat', + resource_kind: null, + combat_timing: null, + delivery_mode: 'instant', + delivery_duration_seconds: null, + targeting_json: null, + value_scaling: 'flat', + unit: '%', + damage_basis: null, + application_phase: 'on_event', + min_state_value: null, + max_state_value: null, + description_slot: 1, + sort_order: 1, + }, + ], + triggers: [], + }; - const adapted = equippedConditionalProgram({ - key: 'weapon:0:exotic-talent', - tier: 2, - rule: persistedRule - }); + const adapted = equippedConditionalProgram({ + key: 'weapon:0:exotic-talent', + tier: 2, + rule: persistedRule, + }); - expect(adapted?.clauses[0].outcomes?.[0]).toMatchObject({ - id: 'weapon-damage-i', - value: 15 - }); - }); + expect(adapted?.clauses[0].outcomes?.[0]).toMatchObject({ + id: 'weapon-damage-i', + value: 15, + }); + }); }); describe('movement-charge exotic mechanic wiring', () => { - const ability: SkillAbilityInput = { - id: 'skill-a', - baseDamage: 100, - damageType: 'kinetic', - delivery: { type: 'direct', hits: 1 }, - castSeconds: 0, - cooldownSeconds: 10, - targetCap: 1, - targetCount: 1, - chargePolicy: { type: 'none' }, - activation: { mode: 'on_cooldown' } - }; - const program: MechanicProgram = { - id: 'movement-charge-exotic', - name: 'Movement Charge Exotic', - scenarioInputs: [ - { - key: 'moving', - label: 'Moving', - kind: 'toggle', - defaultValue: false, - bestCaseValue: true - }, - { - key: 'sprinting', - label: 'Sprinting', - kind: 'toggle', - defaultValue: false, - bestCaseValue: true - } - ], - pools: [ - { - key: 'movement-charge', - label: 'Movement charge', - scope: 'agent', - initial: 0, - min: 0, - max: 40, - bestCaseValue: 40 - }, - { - key: 'kill-speed', - label: 'Kill movement speed', - scope: 'agent', - initial: 0, - min: 0, - max: 1, - expiresAfterSeconds: 10 - } - ], - clauses: [ - { - id: 'gain-while-moving', - when: 'movement_snapshot', - if: { - kind: 'compare', - left: { source: 'event', path: 'values.state' }, - operator: 'eq', - right: 'moving' - }, - reducer: { kind: 'periodic', intervalSeconds: 1 }, - mutations: [{ kind: 'add', pool: 'movement-charge', value: 4 }] - }, - { - id: 'gain-while-sprinting', - when: 'movement_snapshot', - if: { - kind: 'compare', - left: { source: 'event', path: 'values.state' }, - operator: 'eq', - right: 'sprinting' - }, - reducer: { kind: 'periodic', intervalSeconds: 1 }, - mutations: [{ kind: 'add', pool: 'movement-charge', value: 8 }] - }, - { - id: 'spend-on-shot', - when: 'shot_fired', - if: { - kind: 'compare', - left: { source: 'pool', pool: 'movement-charge' }, - operator: 'gt', - right: 0 - }, - reducer: { kind: 'none' }, - mutations: [{ kind: 'consume', pool: 'movement-charge', value: 1 }], - outcomes: [{ - id: 'shot-weapon-damage', - label: 'Shot Weapon Damage', - phase: 'on_event', - kind: 'attribute_modifier', - scope: 'equipped_weapon', - value: 15, - basis: 'flat', - attributeId: 'weapon-damage' - }] - }, - { - id: 'spend-on-skill', - when: 'skill_deployed', - if: { - kind: 'compare', - left: { source: 'pool', pool: 'movement-charge' }, - operator: 'gte', - right: 10 - }, - reducer: { kind: 'none' }, - mutations: [{ kind: 'consume', pool: 'movement-charge', value: 10 }], - outcomes: [{ - id: 'deployment-skill-damage', - label: 'Deployment Skill Damage', - phase: 'on_event', - kind: 'attribute_modifier', - scope: 'all_skills', - value: 15, - basis: 'flat', - attributeId: 'skill-damage' - }] - }, - { - id: 'speed-on-kill', - when: 'enemy_killed', - reducer: { kind: 'none' }, - mutations: [{ kind: 'set', pool: 'kill-speed', value: 1 }], - outcomes: [{ - id: 'kill-movement-speed', - label: 'Kill Movement Speed', - phase: 'while_active', - kind: 'attribute_modifier', - scope: 'self', - value: 10, - basis: 'flat', - attributeId: 'movement-speed', - statePool: 'kill-speed' - }] - } - ] - }; + const ability: SkillAbilityInput = { + id: 'skill-a', + baseDamage: 100, + damageType: 'kinetic', + delivery: { type: 'direct', hits: 1 }, + castSeconds: 0, + cooldownSeconds: 10, + targetCap: 1, + targetCount: 1, + chargePolicy: { type: 'none' }, + activation: { mode: 'on_cooldown' }, + }; + const program: MechanicProgram = { + id: 'movement-charge-exotic', + name: 'Movement Charge Exotic', + scenarioInputs: [ + { + key: 'moving', + label: 'Moving', + kind: 'toggle', + defaultValue: false, + bestCaseValue: true, + }, + { + key: 'sprinting', + label: 'Sprinting', + kind: 'toggle', + defaultValue: false, + bestCaseValue: true, + }, + ], + pools: [ + { + key: 'movement-charge', + label: 'Movement charge', + scope: 'agent', + initial: 0, + min: 0, + max: 40, + bestCaseValue: 40, + }, + { + key: 'kill-speed', + label: 'Kill movement speed', + scope: 'agent', + initial: 0, + min: 0, + max: 1, + expiresAfterSeconds: 10, + }, + ], + clauses: [ + { + id: 'gain-while-moving', + when: 'movement_snapshot', + if: { + kind: 'compare', + left: { source: 'event', path: 'values.state' }, + operator: 'eq', + right: 'moving', + }, + reducer: { kind: 'periodic', intervalSeconds: 1 }, + mutations: [{ kind: 'add', pool: 'movement-charge', value: 4 }], + }, + { + id: 'gain-while-sprinting', + when: 'movement_snapshot', + if: { + kind: 'compare', + left: { source: 'event', path: 'values.state' }, + operator: 'eq', + right: 'sprinting', + }, + reducer: { kind: 'periodic', intervalSeconds: 1 }, + mutations: [{ kind: 'add', pool: 'movement-charge', value: 8 }], + }, + { + id: 'spend-on-shot', + when: 'shot_fired', + if: { + kind: 'compare', + left: { source: 'pool', pool: 'movement-charge' }, + operator: 'gt', + right: 0, + }, + reducer: { kind: 'none' }, + mutations: [{ kind: 'consume', pool: 'movement-charge', value: 1 }], + outcomes: [ + { + id: 'shot-weapon-damage', + label: 'Shot Weapon Damage', + phase: 'on_event', + kind: 'attribute_modifier', + scope: 'equipped_weapon', + value: 15, + basis: 'flat', + attributeId: 'weapon-damage', + }, + ], + }, + { + id: 'spend-on-skill', + when: 'skill_deployed', + if: { + kind: 'compare', + left: { source: 'pool', pool: 'movement-charge' }, + operator: 'gte', + right: 10, + }, + reducer: { kind: 'none' }, + mutations: [{ kind: 'consume', pool: 'movement-charge', value: 10 }], + outcomes: [ + { + id: 'deployment-skill-damage', + label: 'Deployment Skill Damage', + phase: 'on_event', + kind: 'attribute_modifier', + scope: 'all_skills', + value: 15, + basis: 'flat', + attributeId: 'skill-damage', + }, + ], + }, + { + id: 'speed-on-kill', + when: 'enemy_killed', + reducer: { kind: 'none' }, + mutations: [{ kind: 'set', pool: 'kill-speed', value: 1 }], + outcomes: [ + { + id: 'kill-movement-speed', + label: 'Kill Movement Speed', + phase: 'while_active', + kind: 'attribute_modifier', + scope: 'self', + value: 10, + basis: 'flat', + attributeId: 'movement-speed', + statePool: 'kill-speed', + }, + ], + }, + ], + }; - it('gains stacks, consumes them on shots and eligible deployments, and applies timed outcomes', () => { - const profile = createDraft().combatProfile; - profile.encounterSeconds = 15; - const skillDeployments = buildSkillDeploymentEvents([ability], 15); - const events = [ - ...buildWeaponMechanicTimeline( - { rpm: 60, magazine: 100, reload_speed: 0 }, - profile, - 0, - { - scenarioInputs: { moving: true, sprinting: false }, - skillDeploymentEvents: skillDeployments - } - ).filter((event) => - event.kind === 'movement_snapshot' || event.kind === 'skill_deployed'), - { kind: 'shot_fired' as const, at: 3.1 }, - { kind: 'enemy_killed' as const, at: 4 } - ]; - const result = evaluateMechanicProgram( - program, - events, - { - mode: 'custom', - encounterSeconds: 15, - inputs: { moving: true, sprinting: false } - } - ); + it('gains stacks, consumes them on shots and eligible deployments, and applies timed outcomes', () => { + const profile = createDraft().combatProfile; + profile.encounterSeconds = 15; + const skillDeployments = buildSkillDeploymentEvents([ability], 15); + const events = [ + ...buildWeaponMechanicTimeline( + { rpm: 60, magazine: 100, reload_speed: 0 }, + profile, + 0, + { + scenarioInputs: { moving: true, sprinting: false }, + skillDeploymentEvents: skillDeployments, + }, + ).filter( + (event) => + event.kind === 'movement_snapshot' || event.kind === 'skill_deployed', + ), + { kind: 'shot_fired' as const, at: 3.1 }, + { kind: 'enemy_killed' as const, at: 4 }, + ]; + const result = evaluateMechanicProgram(program, events, { + mode: 'custom', + encounterSeconds: 15, + inputs: { moving: true, sprinting: false }, + }); - expect(skillDeployments).toEqual([ - { - kind: 'skill_deployed', - at: 0, - targetId: 'skill-a', - values: { abilityId: 'skill-a' } - }, - { - kind: 'skill_deployed', - at: 10, - targetId: 'skill-a', - values: { abilityId: 'skill-a' } - } - ]); - expect(result.status).toBe('OK'); - expect(result.emissions.filter((emission) => emission.clauseId === 'spend-on-skill')) - .toHaveLength(1); - expect(result.emissions.find((emission) => emission.clauseId === 'spend-on-shot')?.mutations[0]) - .toMatchObject({ before: 12, after: 11 }); - expect(result.emissions.find((emission) => emission.clauseId === 'spend-on-skill')?.mutations[0]) - .toMatchObject({ before: 39, after: 29 }); - expect(result.outcomes).toEqual(expect.arrayContaining([ - expect.objectContaining({ - outcomeId: 'shot-weapon-damage', - at: 3.1, - appliedValue: 15 - }), - expect.objectContaining({ - outcomeId: 'deployment-skill-damage', - at: 10, - targetId: 'skill-a', - appliedValue: 15 - }), - expect.objectContaining({ - outcomeId: 'kill-movement-speed', - at: 4, - appliedValue: 10 - }) - ])); - expect(result.finalState).toContainEqual(expect.objectContaining({ - pool: 'kill-speed', - value: 0, - expiresAt: null - })); + expect(skillDeployments).toEqual([ + { + kind: 'skill_deployed', + at: 0, + targetId: 'skill-a', + values: { abilityId: 'skill-a' }, + }, + { + kind: 'skill_deployed', + at: 10, + targetId: 'skill-a', + values: { abilityId: 'skill-a' }, + }, + ]); + expect(result.status).toBe('OK'); + expect( + result.emissions.filter( + (emission) => emission.clauseId === 'spend-on-skill', + ), + ).toHaveLength(1); + expect( + result.emissions.find((emission) => emission.clauseId === 'spend-on-shot') + ?.mutations[0], + ).toMatchObject({ before: 12, after: 11 }); + expect( + result.emissions.find( + (emission) => emission.clauseId === 'spend-on-skill', + )?.mutations[0], + ).toMatchObject({ before: 39, after: 29 }); + expect(result.outcomes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + outcomeId: 'shot-weapon-damage', + at: 3.1, + appliedValue: 15, + }), + expect.objectContaining({ + outcomeId: 'deployment-skill-damage', + at: 10, + targetId: 'skill-a', + appliedValue: 15, + }), + expect.objectContaining({ + outcomeId: 'kill-movement-speed', + at: 4, + appliedValue: 10, + }), + ]), + ); + expect(result.finalState).toContainEqual( + expect.objectContaining({ + pool: 'kill-speed', + value: 0, + expiresAt: null, + }), + ); - const source = { - status: 'OK' as const, - result, - program, - encounterSeconds: 15, - events - }; - const skillWindows = skillModifierWindowsForMechanic( - source, - null, - new Set(['skill-a']), - new Set(['skill-damage']) - ); - const movementWindows = skillModifierWindowsForMechanic( - source, - null, - new Set(['skill-a']), - new Set(['movement-speed']) - ); - expect(skillWindows).toEqual([{ - startSeconds: 10, - endSeconds: 15, - attributes: { 'skill-damage': 15 }, - abilityIds: ['skill-a'] - }]); - expect(movementWindows).toEqual([{ - startSeconds: 4, - endSeconds: 14, - attributes: { 'movement-speed': 10 }, - abilityIds: undefined - }]); + const source = { + status: 'OK' as const, + result, + program, + encounterSeconds: 15, + events, + }; + const skillWindows = skillModifierWindowsForMechanic( + source, + null, + new Set(['skill-a']), + new Set(['skill-damage']), + ); + const movementWindows = skillModifierWindowsForMechanic( + source, + null, + new Set(['skill-a']), + new Set(['movement-speed']), + ); + expect(skillWindows).toEqual([ + { + startSeconds: 10, + endSeconds: 15, + attributes: { 'skill-damage': 15 }, + abilityIds: ['skill-a'], + }, + ]); + expect(movementWindows).toEqual([ + { + startSeconds: 4, + endSeconds: 14, + attributes: { 'movement-speed': 10 }, + abilityIds: undefined, + }, + ]); - const skillResult = calculateSkillDps({ - rules: { - revisionId: 'test', - bindings: [{ attributeId: 'skill-damage', stage: 'skill_damage', operator: 'add' }] - }, - abilities: [ability], - attributes: {}, - modifierWindows: skillWindows, - assumptions: { encounterSeconds: 15, targetCount: 1 } - }); - expect(skillResult.status).toBe('OK'); - if (skillResult.status === 'OK') { - expect(skillResult.abilities[0].activationCount).toBe(2); - expect(skillResult.abilities[0].damage.singleTargetEncounterDamage).toBe(215); - } - }); + const skillResult = calculateSkillDps({ + rules: { + revisionId: 'test', + bindings: [ + { + attributeId: 'skill-damage', + stage: 'skill_damage', + operator: 'add', + }, + ], + }, + abilities: [ability], + attributes: {}, + modifierWindows: skillWindows, + assumptions: { encounterSeconds: 15, targetCount: 1 }, + }); + expect(skillResult.status).toBe('OK'); + if (skillResult.status === 'OK') { + expect(skillResult.abilities[0].activationCount).toBe(2); + expect(skillResult.abilities[0].damage.singleTargetEncounterDamage).toBe( + 215, + ); + } + }); }); describe('per-shot mechanic modifiers', () => { - const program: MechanicProgram = { - id: 'phase-order', - name: 'Phase order', - pools: [{ - key: 'stacks', - label: 'Stacks', - scope: 'agent', - initial: 0, - min: 0, - max: 10, - expiresAfterSeconds: 10 - }], - clauses: [ - { - id: 'stack-on-hit', - when: 'weapon_hit', - reducer: { kind: 'none' }, - mutations: [{ kind: 'add', pool: 'stacks', value: 1 }], - outcomes: [ - { - id: 'instant-a', - label: 'Instant A', - phase: 'on_event', - kind: 'attribute_modifier', - scope: 'equipped_weapon', - value: 2, - basis: 'flat', - attributeId: 'weapon-damage' - }, - { - id: 'after-hit', - label: 'After hit', - phase: 'after_event', - kind: 'attribute_modifier', - scope: 'equipped_weapon', - value: 100, - basis: 'flat', - attributeId: 'weapon-damage' - }, - { - id: 'active-stacks', - label: 'Active stacks', - phase: 'while_active', - kind: 'attribute_modifier', - scope: 'equipped_weapon', - value: 3, - basis: 'flat', - attributeId: 'weapon-damage', - statePool: 'stacks', - perStack: true - } - ] - }, - { - id: 'second-instant', - when: 'weapon_hit', - reducer: { kind: 'none' }, - mutations: [], - outcomes: [{ - id: 'instant-b', - label: 'Instant B', - phase: 'on_event', - kind: 'attribute_modifier', - scope: 'equipped_weapon', - value: 5, - basis: 'flat', - attributeId: 'weapon-damage' - }] - } - ] - }; - const evaluation = evaluateMechanicProgram( - program, - [ - { kind: 'weapon_hit', at: 1, targetId: 'target' }, - { kind: 'weapon_hit', at: 2, targetId: 'target' } - ], - { mode: 'custom', encounterSeconds: 5 } - ); - const source = { - status: 'OK' as const, - result: evaluation, - program, - encounterSeconds: 5 - }; + const program: MechanicProgram = { + id: 'phase-order', + name: 'Phase order', + pools: [ + { + key: 'stacks', + label: 'Stacks', + scope: 'agent', + initial: 0, + min: 0, + max: 10, + expiresAfterSeconds: 10, + }, + ], + clauses: [ + { + id: 'stack-on-hit', + when: 'weapon_hit', + reducer: { kind: 'none' }, + mutations: [{ kind: 'add', pool: 'stacks', value: 1 }], + outcomes: [ + { + id: 'instant-a', + label: 'Instant A', + phase: 'on_event', + kind: 'attribute_modifier', + scope: 'equipped_weapon', + value: 2, + basis: 'flat', + attributeId: 'weapon-damage', + }, + { + id: 'after-hit', + label: 'After hit', + phase: 'after_event', + kind: 'attribute_modifier', + scope: 'equipped_weapon', + value: 100, + basis: 'flat', + attributeId: 'weapon-damage', + }, + { + id: 'active-stacks', + label: 'Active stacks', + phase: 'while_active', + kind: 'attribute_modifier', + scope: 'equipped_weapon', + value: 3, + basis: 'flat', + attributeId: 'weapon-damage', + statePool: 'stacks', + perStack: true, + }, + ], + }, + { + id: 'second-instant', + when: 'weapon_hit', + reducer: { kind: 'none' }, + mutations: [], + outcomes: [ + { + id: 'instant-b', + label: 'Instant B', + phase: 'on_event', + kind: 'attribute_modifier', + scope: 'equipped_weapon', + value: 5, + basis: 'flat', + attributeId: 'weapon-damage', + }, + ], + }, + ], + }; + const evaluation = evaluateMechanicProgram( + program, + [ + { kind: 'weapon_hit', at: 1, targetId: 'target' }, + { kind: 'weapon_hit', at: 2, targetId: 'target' }, + ], + { mode: 'custom', encounterSeconds: 5 }, + ); + const source = { + status: 'OK' as const, + result: evaluation, + program, + encounterSeconds: 5, + }; - it('combines same-attribute on-event effects and excludes after-event effects', () => { - expect(mechanicModifiersAt(source, 1, 'weapon_hit')).toEqual([{ - attributeId: 'weapon-damage', - scope: 'equipped_weapon', - value: 7 - }]); - }); + it('combines same-attribute on-event effects and excludes after-event effects', () => { + expect(mechanicModifiersAt(source, 1, 'weapon_hit')).toEqual([ + { + attributeId: 'weapon-damage', + scope: 'equipped_weapon', + value: 7, + }, + ]); + }); - it('uses the pre-hit active stack state when the triggering hit adds another stack', () => { - expect(mechanicModifiersAt(source, 1.5, 'weapon_hit')[0]?.value).toBe(3); - expect(mechanicModifiersAt(source, 2, 'weapon_hit')[0]?.value).toBe(10); - }); + it('uses the pre-hit active stack state when the triggering hit adds another stack', () => { + expect(mechanicModifiersAt(source, 1.5, 'weapon_hit')[0]?.value).toBe(3); + expect(mechanicModifiersAt(source, 2, 'weapon_hit')[0]?.value).toBe(10); + }); }); diff --git a/src/lib/builder/loadout-combat.ts b/src/lib/builder/loadout-combat.ts index 68244f6..3796758 100644 --- a/src/lib/builder/loadout-combat.ts +++ b/src/lib/builder/loadout-combat.ts @@ -1,1357 +1,1809 @@ import { - calculateCombat, - expectedWeaponShotDamage, - resolveWeaponFiringCycle, - type AttributeBinding, - type CalculationStage + calculateCombat, + expectedWeaponShotDamage, + resolveWeaponFiringCycle, + type AttributeBinding, + type CalculationStage, } from '$lib/calculations/combat'; import { - evaluateMechanicProgram, - type MechanicCombatContext, - type MechanicEvaluation, - type MechanicEvent, - type MechanicOutcome, - type MechanicProgram, - type NumericExpression + evaluateMechanicProgram, + type MechanicCombatContext, + type MechanicEvaluation, + type MechanicEvent, + type MechanicOutcome, + type MechanicProgram, + type NumericExpression, } from '$lib/calculations/mechanic'; import { adaptConditionalEffectProgram } from '$lib/calculations/mechanic-persistence'; import { - calculateSkillDps, - skillActivationTimes, - type SkillAbilityInput, - type SkillAttributeBinding, - type SkillCalculation, - type SkillCalculationInput, - type SkillCalculationRules, - type SkillModifierWindow + calculateSkillDps, + skillActivationTimes, + type SkillAbilityInput, + type SkillAttributeBinding, + type SkillCalculation, + type SkillCalculationInput, + type SkillCalculationRules, + type SkillModifierWindow, } from '$lib/calculations/skill'; import { gearSlots, scaled, type LoadoutDraft } from '$lib/builder/loadout'; import type { WeaponCalculationResult } from '$lib/components/builder/WeaponCalculationResult.svelte'; type Catalog = { - attributes: any[]; - gear: any[]; - gearAugments: any[]; - brands: any[]; - weapons: any[]; - talents: any[]; - chipSets: any[]; - protocols: any[]; - specializations: any[]; - calculation: { revision: any | null; stages: any[]; profiles: any[]; skillProfiles?: any[] }; + attributes: any[]; + gear: any[]; + gearAugments: any[]; + brands: any[]; + weapons: any[]; + talents: any[]; + chipSets: any[]; + protocols: any[]; + specializations: any[]; + calculation: { + revision: any | null; + stages: any[]; + profiles: any[]; + skillProfiles?: any[]; + }; }; export type LoadoutSummary = { - stats: Array<{ id: string; value: number; sourceCount: number }>; - activeBonuses: any[]; - equippedTalents: any[]; + stats: Array<{ id: string; value: number; sourceCount: number }>; + activeBonuses: any[]; + equippedTalents: any[]; }; const coreKeys = ['firepower', 'toughness', 'engineering'] as const; function selectedFocus(draft: LoadoutDraft, catalog: Catalog) { - return catalog.specializations - .flatMap((specialization) => specialization.focuses.map((focus: any) => ({ ...focus, specialization }))) - .find((focus: any) => focus.id === draft.specialization.focusId); + return catalog.specializations + .flatMap((specialization) => + specialization.focuses.map((focus: any) => ({ + ...focus, + specialization, + })), + ) + .find((focus: any) => focus.id === draft.specialization.focusId); } function selectedNodeRanks(draft: LoadoutDraft, catalog: Catalog) { - const focus = selectedFocus(draft, catalog); - return focus?.nodes.flatMap((node: any) => { - const selectedRank = Number(draft.specialization.nodeRanks[node.id] ?? 0); - return selectedRank - ? [{ node, rank: node.ranks.find((entry: any) => Number(entry.rank) === selectedRank) }] - : []; - }) ?? []; + const focus = selectedFocus(draft, catalog); + return ( + focus?.nodes.flatMap((node: any) => { + const selectedRank = Number(draft.specialization.nodeRanks[node.id] ?? 0); + return selectedRank + ? [ + { + node, + rank: node.ranks.find( + (entry: any) => Number(entry.rank) === selectedRank, + ), + }, + ] + : []; + }) ?? [] + ); } function attribute(catalog: Catalog, id: string) { - return catalog.attributes.find((item) => item.id === id); + return catalog.attributes.find((item) => item.id === id); } -function applyAttributeValue(catalog: Catalog, current: number, statId: string, incoming: number) { - const stat = attribute(catalog, statId); - const combined = stat?.calculation_type === 'Multiplicative' && stat?.unit === '%' - ? ((1 + current / 100) * (1 + incoming / 100) - 1) * 100 - : current + incoming; - return stat?.cap_value === null || stat?.cap_value === undefined - ? combined - : Math.min(combined, Number(stat.cap_value)); +function applyAttributeValue( + catalog: Catalog, + current: number, + statId: string, + incoming: number, +) { + const stat = attribute(catalog, statId); + const combined = + stat?.calculation_type === 'Multiplicative' && stat?.unit === '%' + ? ((1 + current / 100) * (1 + incoming / 100) - 1) * 100 + : current + incoming; + return stat?.cap_value === null || stat?.cap_value === undefined + ? combined + : Math.min(combined, Number(stat.cap_value)); } function unitValue(catalog: Catalog, value: unknown, statId: string) { - const numeric = Number(value); - const rendered = Number.isInteger(numeric) ? String(numeric) : numeric.toFixed(2).replace(/\.?0+$/, ''); - const unit = attribute(catalog, statId)?.unit ?? ''; - return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`; + const numeric = Number(value); + const rendered = Number.isInteger(numeric) + ? String(numeric) + : numeric.toFixed(2).replace(/\.?0+$/, ''); + const unit = attribute(catalog, statId)?.unit ?? ''; + return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`; } function talentTier(catalog: Catalog, talentId: string, tier: number) { - const item = catalog.talents.find((entry) => entry.id === talentId); - return item?.tiers.find((entry: any) => entry.tier === (tier >= 2 ? 'II' : 'I')) ?? item?.tiers[0]; + const item = catalog.talents.find((entry) => entry.id === talentId); + return ( + item?.tiers.find((entry: any) => entry.tier === (tier >= 2 ? 'II' : 'I')) ?? + item?.tiers[0] + ); } function talentDescription(catalog: Catalog, talentId: string, tier: number) { - const item = catalog.talents.find((entry) => entry.id === talentId); - const configured = talentTier(catalog, talentId, tier); - if (!item || !configured) return ''; - const template = String(item.template || item.name); - return template.replace(/\{(\d+)\}/g, (placeholder, raw) => { - const effect = configured.effects[Number(raw) - 1]; - return effect ? unitValue(catalog, effect.value, effect.stat_attribute_id) : placeholder; - }); + const item = catalog.talents.find((entry) => entry.id === talentId); + const configured = talentTier(catalog, talentId, tier); + if (!item || !configured) return ''; + const template = String(item.template || item.name); + return template.replace(/\{(\d+)\}/g, (placeholder, raw) => { + const effect = configured.effects[Number(raw) - 1]; + return effect + ? unitValue(catalog, effect.value, effect.stat_attribute_id) + : placeholder; + }); } -export function summarizeLoadout(draft: LoadoutDraft, catalog: Catalog): LoadoutSummary { - const values = new Map(); - const sources = new Map>(); - const add = (statId: string, value: unknown, source: string) => { - if (!statId || !Number.isFinite(Number(value))) return; - const stat = attribute(catalog, statId); - const current = values.get(statId) ?? 0; - const incoming = Number(value); - const combined = stat?.calculation_type === 'Multiplicative' && stat?.unit === '%' - ? ((1 + current / 100) * (1 + incoming / 100) - 1) * 100 - : current + incoming; - values.set( - statId, - stat?.cap_value === null || stat?.cap_value === undefined - ? combined - : Math.min(combined, Number(stat.cap_value)) - ); - if (!sources.has(statId)) sources.set(statId, new Set()); - sources.get(statId)!.add(source); - }; - const byName = (name: string) => - catalog.attributes.find((item) => item.name.toLocaleLowerCase() === name.toLocaleLowerCase())?.id ?? ''; - const brandCounts = new Map(); - const activeBonuses: any[] = []; - const equippedTalents: any[] = []; +export function summarizeLoadout( + draft: LoadoutDraft, + catalog: Catalog, +): LoadoutSummary { + const values = new Map(); + const sources = new Map>(); + const add = (statId: string, value: unknown, source: string) => { + if (!statId || !Number.isFinite(Number(value))) return; + const stat = attribute(catalog, statId); + const current = values.get(statId) ?? 0; + const incoming = Number(value); + const combined = + stat?.calculation_type === 'Multiplicative' && stat?.unit === '%' + ? ((1 + current / 100) * (1 + incoming / 100) - 1) * 100 + : current + incoming; + values.set( + statId, + stat?.cap_value === null || stat?.cap_value === undefined + ? combined + : Math.min(combined, Number(stat.cap_value)), + ); + if (!sources.has(statId)) sources.set(statId, new Set()); + sources.get(statId)!.add(source); + }; + const byName = (name: string) => + catalog.attributes.find( + (item) => item.name.toLocaleLowerCase() === name.toLocaleLowerCase(), + )?.id ?? ''; + const brandCounts = new Map(); + const activeBonuses: any[] = []; + const equippedTalents: any[] = []; - for (const slot of gearSlots) { - const selection = draft.gear[slot]; - const item = catalog.gear.find((entry) => entry.id === selection.itemId); - if (!item) continue; - const enabledAugments = catalog.gearAugments.filter( - (entry) => entry.gear_slot === slot && selection.augmentSlots.includes(Number(entry.augment_slot)) - ); - const basePercentage = enabledAugments - .filter((entry) => entry.effect_type === 'Base Percentage') - .reduce((total, entry) => total + Number(entry.value), 0); - add(byName('Health'), selection.health * (1 + basePercentage / 100), `${slot} base`); - if (item.base_armor_min !== null) add(byName('Armor'), selection.armor * (1 + basePercentage / 100), `${slot} base`); - for (const roll of selection.rolls) add(roll.statId, roll.value, `${slot} roll`); - for (const key of coreKeys) add(byName(key), selection[key], `${slot} core`); - if (selection.brandId) brandCounts.set(selection.brandId, (brandCounts.get(selection.brandId) ?? 0) + 1); - for (const augment of enabledAugments.filter((entry) => entry.effect_type === 'Custom')) { - add(augment.stat_attribute_id, augment.value, `${slot} augment ${augment.augment_slot}`); - } - if (selection.talentId) { - equippedTalents.push({ - key: `gear:${slot}:${selection.talentId}`, - id: selection.talentId, - rule: catalog.talents.find((entry) => entry.id === selection.talentId)?.rule ?? null, - tier: Number(item.tier), - source: slot, - description: talentDescription(catalog, selection.talentId, Number(item.tier)) - }); - } - } + for (const slot of gearSlots) { + const selection = draft.gear[slot]; + const item = catalog.gear.find((entry) => entry.id === selection.itemId); + if (!item) continue; + const enabledAugments = catalog.gearAugments.filter( + (entry) => + entry.gear_slot === slot && + selection.augmentSlots.includes(Number(entry.augment_slot)), + ); + const basePercentage = enabledAugments + .filter((entry) => entry.effect_type === 'Base Percentage') + .reduce((total, entry) => total + Number(entry.value), 0); + add( + byName('Health'), + selection.health * (1 + basePercentage / 100), + `${slot} base`, + ); + if (item.base_armor_min !== null) + add( + byName('Armor'), + selection.armor * (1 + basePercentage / 100), + `${slot} base`, + ); + for (const roll of selection.rolls) + add(roll.statId, roll.value, `${slot} roll`); + for (const key of coreKeys) + add(byName(key), selection[key], `${slot} core`); + if (selection.brandId) + brandCounts.set( + selection.brandId, + (brandCounts.get(selection.brandId) ?? 0) + 1, + ); + for (const augment of enabledAugments.filter( + (entry) => entry.effect_type === 'Custom', + )) { + add( + augment.stat_attribute_id, + augment.value, + `${slot} augment ${augment.augment_slot}`, + ); + } + if (selection.talentId) { + equippedTalents.push({ + key: `gear:${slot}:${selection.talentId}`, + id: selection.talentId, + rule: + catalog.talents.find((entry) => entry.id === selection.talentId) + ?.rule ?? null, + tier: Number(item.tier), + source: slot, + description: talentDescription( + catalog, + selection.talentId, + Number(item.tier), + ), + }); + } + } - for (const [brandId, count] of brandCounts) { - const set = catalog.brands.find((entry) => entry.id === brandId); - for (const effect of set?.effects.filter((entry: any) => Number(entry.pieces_required) <= count) ?? []) { - add(effect.stat_attribute_id, effect.value, `${set.name} ${effect.pieces_required}-piece`); - activeBonuses.push({ - name: set.name, - pieces: effect.pieces_required, - count, - statId: effect.stat_attribute_id, - value: effect.value - }); - } - } + for (const [brandId, count] of brandCounts) { + const set = catalog.brands.find((entry) => entry.id === brandId); + for (const effect of set?.effects.filter( + (entry: any) => Number(entry.pieces_required) <= count, + ) ?? []) { + add( + effect.stat_attribute_id, + effect.value, + `${set.name} ${effect.pieces_required}-piece`, + ); + activeBonuses.push({ + name: set.name, + pieces: effect.pieces_required, + count, + statId: effect.stat_attribute_id, + value: effect.value, + }); + } + } - for (let index = 0; index < 2; index += 1) { - const selection = draft.weapons[index]; - const item = catalog.weapons.find((entry) => entry.id === selection.itemId); - if (!item) continue; - for (const name of ['Firepower', 'Toughness', 'Engineering'] as const) { - add(byName(name), selection[name.toLowerCase() as 'firepower'], `${index ? 'Secondary' : 'Primary'} weapon`); - } - for (const bonus of item.baseBonuses ?? []) add(bonus.stat_attribute_id, bonus.value, `${item.name} base bonus`); - for (const roll of selection.rolls) add(roll.statId, roll.value, `${index ? 'Secondary' : 'Primary'} weapon roll`); - for (const talentId of [selection.talent1Id, selection.talent2Id, item.exotic_talent_id].filter(Boolean)) { - equippedTalents.push({ - key: `weapon:${index}:${talentId}`, - id: talentId, - rule: catalog.talents.find((entry) => entry.id === talentId)?.rule ?? null, - tier: selection.tier, - source: index ? 'Secondary weapon' : 'Primary weapon', - description: talentDescription(catalog, talentId, selection.tier) - }); - } - } + for (let index = 0; index < 2; index += 1) { + const selection = draft.weapons[index]; + const item = catalog.weapons.find((entry) => entry.id === selection.itemId); + if (!item) continue; + for (const name of ['Firepower', 'Toughness', 'Engineering'] as const) { + add( + byName(name), + selection[name.toLowerCase() as 'firepower'], + `${index ? 'Secondary' : 'Primary'} weapon`, + ); + } + for (const bonus of item.baseBonuses ?? []) + add(bonus.stat_attribute_id, bonus.value, `${item.name} base bonus`); + for (const roll of selection.rolls) + add( + roll.statId, + roll.value, + `${index ? 'Secondary' : 'Primary'} weapon roll`, + ); + for (const talentId of [ + selection.talent1Id, + selection.talent2Id, + item.exotic_talent_id, + ].filter(Boolean)) { + equippedTalents.push({ + key: `weapon:${index}:${talentId}`, + id: talentId, + rule: + catalog.talents.find((entry) => entry.id === talentId)?.rule ?? null, + tier: selection.tier, + source: index ? 'Secondary weapon' : 'Primary weapon', + description: talentDescription(catalog, talentId, selection.tier), + }); + } + } - const chipGroups = new Map(); - for (let index = 0; index < 9; index += 1) { - const selection = draft.chips[index]; - const item = catalog.chipSets.find((entry) => entry.id === selection.setId); - if (!item) continue; - add(selection.primary.statId, selection.primary.value, `Skill Chip ${index + 1} primary`); - for (const roll of selection.secondary) add(roll.statId, roll.value, `Skill Chip ${index + 1} secondary`); - const group = chipGroups.get(item.id) ?? { count: 0, highEnd: 0, item }; - group.count += 1; - if (selection.quality === 'High-end') group.highEnd += 1; - chipGroups.set(item.id, group); - } - for (const group of chipGroups.values()) { - if (group.count >= 2 && group.item.twoPiece) { - const value = scaled(group.item.twoPiece.min_value, group.item.twoPiece.max_value, group.highEnd, 3); - add(group.item.twoPiece.stat_attribute_id, value, `${group.item.name} 2-piece`); - activeBonuses.push({ - name: group.item.name, - pieces: 2, - count: group.count, - statId: group.item.twoPiece.stat_attribute_id, - value - }); - } - if (group.count === 3 && group.item.talent) { - equippedTalents.push({ - key: `chips:${group.item.id}`, - id: '', - custom: group.item.talent, - rule: group.item.talent.rule ?? null, - tier: group.highEnd, - abilityId: group.item.specialization_ability_id ?? null, - source: '3 Skill Chips', - description: group.item.talent.description_template.replace(/\{(\d+)\}/g, (placeholder: string, raw: string) => { - const effect = group.item.talent.effects[Number(raw) - 1]; - return effect - ? unitValue(catalog, scaled(effect.min_value, effect.max_value, group.highEnd, 3), effect.stat_attribute_id) - : placeholder; - }) - }); - } - } + const chipGroups = new Map< + string, + { count: number; highEnd: number; item: any } + >(); + for (let index = 0; index < 9; index += 1) { + const selection = draft.chips[index]; + const item = catalog.chipSets.find((entry) => entry.id === selection.setId); + if (!item) continue; + add( + selection.primary.statId, + selection.primary.value, + `Skill Chip ${index + 1} primary`, + ); + for (const roll of selection.secondary) + add(roll.statId, roll.value, `Skill Chip ${index + 1} secondary`); + const group = chipGroups.get(item.id) ?? { count: 0, highEnd: 0, item }; + group.count += 1; + if (selection.quality === 'High-end') group.highEnd += 1; + chipGroups.set(item.id, group); + } + for (const group of chipGroups.values()) { + if (group.count >= 2 && group.item.twoPiece) { + const value = scaled( + group.item.twoPiece.min_value, + group.item.twoPiece.max_value, + group.highEnd, + 3, + ); + add( + group.item.twoPiece.stat_attribute_id, + value, + `${group.item.name} 2-piece`, + ); + activeBonuses.push({ + name: group.item.name, + pieces: 2, + count: group.count, + statId: group.item.twoPiece.stat_attribute_id, + value, + }); + } + if (group.count === 3 && group.item.talent) { + equippedTalents.push({ + key: `chips:${group.item.id}`, + id: '', + custom: group.item.talent, + rule: group.item.talent.rule ?? null, + tier: group.highEnd, + abilityId: group.item.specialization_ability_id ?? null, + source: '3 Skill Chips', + description: group.item.talent.description_template.replace( + /\{(\d+)\}/g, + (placeholder: string, raw: string) => { + const effect = group.item.talent.effects[Number(raw) - 1]; + return effect + ? unitValue( + catalog, + scaled(effect.min_value, effect.max_value, group.highEnd, 3), + effect.stat_attribute_id, + ) + : placeholder; + }, + ), + }); + } + } - for (const { node, rank } of selectedNodeRanks(draft, catalog)) { - for (const effect of rank?.effects ?? []) { - if (effect.effect_type === 'Attribute') add(effect.stat_attribute_id, effect.value, `${node.name} rank ${rank.rank}`); - } - } + for (const { node, rank } of selectedNodeRanks(draft, catalog)) { + for (const effect of rank?.effects ?? []) { + if (effect.effect_type === 'Attribute') + add( + effect.stat_attribute_id, + effect.value, + `${node.name} rank ${rank.rank}`, + ); + } + } - const protocol = catalog.protocols.find((entry) => entry.id === draft.protocol.itemId); - if (protocol) { - if (protocol.primaryEffect) { - add( - protocol.primaryEffect.stat_attribute_id, - Number(protocol.primaryEffect.base_value) * (1 + (draft.protocol.level - 1) / 49), - `${protocol.name} primary` - ); - } - for (const effect of protocol.secondaryEffects ?? []) { - add(effect.stat_attribute_id, effect.value, `${protocol.name} secondary`); - } - if (protocol.talent) { - equippedTalents.push({ - key: `protocol:${protocol.id}`, - id: '', - custom: protocol.talent, - rule: protocol.talent.rule ?? null, - tier: draft.protocol.talentUpgrades, - source: 'OS Protocol', - description: protocol.talent.description_template.replace(/\{(\d+)\}/g, (placeholder: string, raw: string) => { - const effect = protocol.talent.effects[Number(raw) - 1]; - return effect - ? unitValue( - catalog, - Number(effect.base_value) * (1 + draft.protocol.talentUpgrades / 5), - effect.stat_attribute_id - ) - : placeholder; - }) - }); - } - } + const protocol = catalog.protocols.find( + (entry) => entry.id === draft.protocol.itemId, + ); + if (protocol) { + if (protocol.primaryEffect) { + add( + protocol.primaryEffect.stat_attribute_id, + Number(protocol.primaryEffect.base_value) * + (1 + (draft.protocol.level - 1) / 49), + `${protocol.name} primary`, + ); + } + for (const effect of protocol.secondaryEffects ?? []) { + add(effect.stat_attribute_id, effect.value, `${protocol.name} secondary`); + } + if (protocol.talent) { + equippedTalents.push({ + key: `protocol:${protocol.id}`, + id: '', + custom: protocol.talent, + rule: protocol.talent.rule ?? null, + tier: draft.protocol.talentUpgrades, + source: 'OS Protocol', + description: protocol.talent.description_template.replace( + /\{(\d+)\}/g, + (placeholder: string, raw: string) => { + const effect = protocol.talent.effects[Number(raw) - 1]; + return effect + ? unitValue( + catalog, + Number(effect.base_value) * + (1 + draft.protocol.talentUpgrades / 5), + effect.stat_attribute_id, + ) + : placeholder; + }, + ), + }); + } + } - return { - stats: [...values] - .map(([id, value]) => ({ id, value, sourceCount: sources.get(id)?.size ?? 0 })) - .sort((left, right) => - (attribute(catalog, left.id)?.category ?? '').localeCompare(attribute(catalog, right.id)?.category ?? '') - || (attribute(catalog, left.id)?.name ?? '').localeCompare(attribute(catalog, right.id)?.name ?? '') - ), - activeBonuses, - equippedTalents - }; + return { + stats: [...values] + .map(([id, value]) => ({ + id, + value, + sourceCount: sources.get(id)?.size ?? 0, + })) + .sort( + (left, right) => + (attribute(catalog, left.id)?.category ?? '').localeCompare( + attribute(catalog, right.id)?.category ?? '', + ) || + (attribute(catalog, left.id)?.name ?? '').localeCompare( + attribute(catalog, right.id)?.name ?? '', + ), + ), + activeBonuses, + equippedTalents, + }; } -function calculationStage(catalog: Catalog, stageKey: string): CalculationStage | null { - if ([ - 'flat_damage', - 'weapon_damage', - 'critical_chance', - 'critical_damage', - 'headshot_chance', - 'headshot_damage', - 'rate_of_fire', - 'magazine_size', - 'reload_speed', - 'final_damage' - ].includes(stageKey)) { - return stageKey as CalculationStage; - } - const stage = catalog.calculation.stages.find((item) => item.stage_key === stageKey); - if (!stage) return null; - if (stage.operator === 'flat_add') return 'flat_damage'; - if (stage.operator === 'additive_percent') return 'weapon_damage'; - if (stage.operator === 'multiplicative_percent') return 'final_damage'; - if (stage.operator === 'expected_critical') return stageKey.includes('chance') ? 'critical_chance' : 'critical_damage'; - if (stage.operator === 'expected_headshot') return stageKey.includes('chance') ? 'headshot_chance' : 'headshot_damage'; - if (stage.operator === 'rate_of_fire') return 'rate_of_fire'; - if (stage.operator === 'magazine_size') return 'magazine_size'; - if (stage.operator === 'reload_speed') return 'reload_speed'; - return null; +function calculationStage( + catalog: Catalog, + stageKey: string, +): CalculationStage | null { + if ( + [ + 'flat_damage', + 'weapon_damage', + 'critical_chance', + 'critical_damage', + 'headshot_chance', + 'headshot_damage', + 'rate_of_fire', + 'magazine_size', + 'reload_speed', + 'final_damage', + ].includes(stageKey) + ) { + return stageKey as CalculationStage; + } + const stage = catalog.calculation.stages.find( + (item) => item.stage_key === stageKey, + ); + if (!stage) return null; + if (stage.operator === 'flat_add') return 'flat_damage'; + if (stage.operator === 'additive_percent') return 'weapon_damage'; + if (stage.operator === 'multiplicative_percent') return 'final_damage'; + if (stage.operator === 'expected_critical') + return stageKey.includes('chance') ? 'critical_chance' : 'critical_damage'; + if (stage.operator === 'expected_headshot') + return stageKey.includes('chance') ? 'headshot_chance' : 'headshot_damage'; + if (stage.operator === 'rate_of_fire') return 'rate_of_fire'; + if (stage.operator === 'magazine_size') return 'magazine_size'; + if (stage.operator === 'reload_speed') return 'reload_speed'; + return null; } -function calculationBindings(catalog: Catalog, weapon: any): AttributeBinding[] { - return catalog.calculation.profiles - .filter((item) => item.weapon_category === '*' || item.weapon_category === weapon?.category) - .flatMap((profile) => { - const stage = calculationStage(catalog, String(profile.stage_key)); - return stage - ? [{ - attributeId: String(profile.stat_attribute_id), - stage, - operator: 'add' as const, - weight: Number(profile.weight), - softCap: profile.soft_cap, - hardCap: profile.hard_cap, - curveType: profile.curve_type, - curveParameter: profile.curve_parameter - }] - : []; - }); +function calculationBindings( + catalog: Catalog, + weapon: any, +): AttributeBinding[] { + return catalog.calculation.profiles + .filter( + (item) => + item.weapon_category === '*' || + item.weapon_category === weapon?.category, + ) + .flatMap((profile) => { + const stage = calculationStage(catalog, String(profile.stage_key)); + return stage + ? [ + { + attributeId: String(profile.stat_attribute_id), + stage, + operator: 'add' as const, + weight: Number(profile.weight), + softCap: profile.soft_cap, + hardCap: profile.hard_cap, + curveType: profile.curve_type, + curveParameter: profile.curve_parameter, + }, + ] + : []; + }); } function transformedBindingValue(raw: number, binding: AttributeBinding) { - const weight = Number.isFinite(binding.weight) ? Number(binding.weight) : 1; - const sign = raw < 0 ? -1 : 1; - let value = Math.abs(raw * weight); - const softCap = Number.isFinite(Number(binding.softCap)) && Number(binding.softCap) >= 0 - ? Number(binding.softCap) - : null; - const hardCap = Number.isFinite(Number(binding.hardCap)) && Number(binding.hardCap) >= 0 - ? Number(binding.hardCap) - : null; - if (softCap !== null && value > softCap && binding.curveType && binding.curveType !== 'none') { - const parameter = Math.max(0.01, Number(binding.curveParameter ?? 1)); - const overflow = value - softCap; - if (binding.curveType === 'power') { - value = softCap + overflow * Math.pow(softCap / Math.max(softCap + overflow, 0.000_001), parameter); - } else { - const ceiling = hardCap ?? Math.max(softCap * 2, softCap + 1); - const ratio = Math.min(1, Math.max(0, overflow / Math.max(ceiling - softCap, 0.000_001))); - value = softCap + (ceiling - softCap) * (ratio * ratio * (3 - 2 * ratio)); - } - } - if (hardCap !== null) value = Math.min(value, hardCap); - return value * sign; + const weight = Number.isFinite(binding.weight) ? Number(binding.weight) : 1; + const sign = raw < 0 ? -1 : 1; + let value = Math.abs(raw * weight); + const softCap = + Number.isFinite(Number(binding.softCap)) && Number(binding.softCap) >= 0 + ? Number(binding.softCap) + : null; + const hardCap = + Number.isFinite(Number(binding.hardCap)) && Number(binding.hardCap) >= 0 + ? Number(binding.hardCap) + : null; + if ( + softCap !== null && + value > softCap && + binding.curveType && + binding.curveType !== 'none' + ) { + const parameter = Math.max(0.01, Number(binding.curveParameter ?? 1)); + const overflow = value - softCap; + if (binding.curveType === 'power') { + value = + softCap + + overflow * + Math.pow( + softCap / Math.max(softCap + overflow, 0.000_001), + parameter, + ); + } else { + const ceiling = hardCap ?? Math.max(softCap * 2, softCap + 1); + const ratio = Math.min( + 1, + Math.max(0, overflow / Math.max(ceiling - softCap, 0.000_001)), + ); + value = softCap + (ceiling - softCap) * (ratio * ratio * (3 - 2 * ratio)); + } + } + if (hardCap !== null) value = Math.min(value, hardCap); + return value * sign; } export function weaponCriticalChance( - attributes: Record, - bindings: AttributeBinding[] + attributes: Record, + bindings: AttributeBinding[], ) { - return Math.min(100, Math.max(0, bindings - .filter((binding) => binding.stage === 'critical_chance' && binding.operator === 'add') - .reduce((total, binding) => - total + transformedBindingValue(Number(attributes[binding.attributeId] ?? 0), binding), 0))); + return Math.min( + 100, + Math.max( + 0, + bindings + .filter( + (binding) => + binding.stage === 'critical_chance' && binding.operator === 'add', + ) + .reduce( + (total, binding) => + total + + transformedBindingValue( + Number(attributes[binding.attributeId] ?? 0), + binding, + ), + 0, + ), + ), + ); } function conditionalProgram( - rule: any, - variantKey: string, - valueScale = 1 + rule: any, + variantKey: string, + valueScale = 1, ): MechanicProgram | null { - if (!rule?.rule) return null; - const adapted = adaptConditionalEffectProgram(rule, { variantKey }); - if (!adapted.program || adapted.status === 'INVALID' || adapted.status === 'CAPTURED_ONLY') return null; - if (valueScale === 1) return adapted.program; - const scale = (value: NumericExpression): NumericExpression => - typeof value === 'number' ? value * valueScale : value; - return { - ...adapted.program, - clauses: adapted.program.clauses.map((clause) => ({ - ...clause, - outcomes: clause.outcomes?.map((outcome) => ({ ...outcome, value: scale(outcome.value) })) - })) - }; + if (!rule?.rule) return null; + const adapted = adaptConditionalEffectProgram(rule, { variantKey }); + if ( + !adapted.program || + adapted.status === 'INVALID' || + adapted.status === 'CAPTURED_ONLY' + ) + return null; + if (valueScale === 1) return adapted.program; + const scale = (value: NumericExpression): NumericExpression => + typeof value === 'number' ? value * valueScale : value; + return { + ...adapted.program, + clauses: adapted.program.clauses.map((clause) => ({ + ...clause, + outcomes: clause.outcomes?.map((outcome) => ({ + ...outcome, + value: scale(outcome.value), + })), + })), + }; } function interpolatedConditionalProgram( - rule: any, - minimumVariant: string, - maximumVariant: string, - step: number, - totalSteps: number + rule: any, + minimumVariant: string, + maximumVariant: string, + step: number, + totalSteps: number, ): MechanicProgram | null { - const minimum = conditionalProgram(rule, minimumVariant); - const maximum = conditionalProgram(rule, maximumVariant); - if (!minimum || !maximum) return minimum ?? maximum; - const ratio = Math.max(0, Math.min(1, Number(step) / Math.max(1, Number(totalSteps)))); - const maximumByKey = new Map(maximum.clauses.flatMap((clause) => - (clause.outcomes ?? []).map((outcome) => [`${clause.id}:${outcome.id}`, outcome] as const) - )); - return { - ...minimum, - clauses: minimum.clauses.map((clause) => ({ - ...clause, - outcomes: clause.outcomes?.map((outcome) => { - const upper = maximumByKey.get(`${clause.id}:${outcome.id}`); - return upper && typeof outcome.value === 'number' && typeof upper.value === 'number' - ? { ...outcome, value: outcome.value + (upper.value - outcome.value) * ratio } - : outcome; - }) - })) - }; + const minimum = conditionalProgram(rule, minimumVariant); + const maximum = conditionalProgram(rule, maximumVariant); + if (!minimum || !maximum) return minimum ?? maximum; + const ratio = Math.max( + 0, + Math.min(1, Number(step) / Math.max(1, Number(totalSteps))), + ); + const maximumByKey = new Map( + maximum.clauses.flatMap((clause) => + (clause.outcomes ?? []).map( + (outcome) => [`${clause.id}:${outcome.id}`, outcome] as const, + ), + ), + ); + return { + ...minimum, + clauses: minimum.clauses.map((clause) => ({ + ...clause, + outcomes: clause.outcomes?.map((outcome) => { + const upper = maximumByKey.get(`${clause.id}:${outcome.id}`); + return upper && + typeof outcome.value === 'number' && + typeof upper.value === 'number' + ? { + ...outcome, + value: outcome.value + (upper.value - outcome.value) * ratio, + } + : outcome; + }), + })), + }; } -export function equippedConditionalProgram(equipped: any): MechanicProgram | null { - if (String(equipped.key).startsWith('chips:')) { - return interpolatedConditionalProgram(equipped.rule, 'minimum', 'maximum', Number(equipped.tier), 3); - } - if (String(equipped.key).startsWith('protocol:')) { - return conditionalProgram(equipped.rule, 'base'); - } - const equipmentTalent = String(equipped.key).startsWith('weapon:') - || String(equipped.key).startsWith('gear:'); - const variant = equipmentTalent - ? (Number(equipped.tier) >= 2 ? 'II' : 'I') - : 'base'; - return conditionalProgram(equipped.rule, variant) - ?? (equipmentTalent && variant === 'II' - ? conditionalProgram(equipped.rule, 'I') - : null); +export function equippedConditionalProgram( + equipped: any, +): MechanicProgram | null { + if (String(equipped.key).startsWith('chips:')) { + return interpolatedConditionalProgram( + equipped.rule, + 'minimum', + 'maximum', + Number(equipped.tier), + 3, + ); + } + if (String(equipped.key).startsWith('protocol:')) { + return conditionalProgram(equipped.rule, 'base'); + } + const equipmentTalent = + String(equipped.key).startsWith('weapon:') || + String(equipped.key).startsWith('gear:'); + const variant = equipmentTalent + ? Number(equipped.tier) >= 2 + ? 'II' + : 'I' + : 'base'; + return ( + conditionalProgram(equipped.rule, variant) ?? + (equipmentTalent && variant === 'II' + ? conditionalProgram(equipped.rule, 'I') + : null) + ); } export type ActiveMechanicEvaluation = { - status: 'OK'; - result: MechanicEvaluation; - program: MechanicProgram; - encounterSeconds: number; - events?: MechanicEvent[]; + status: 'OK'; + result: MechanicEvaluation; + program: MechanicProgram; + encounterSeconds: number; + events?: MechanicEvent[]; }; function mechanicScenarioInputs( - program: MechanicProgram, - configured: unknown, - maximum = false + program: MechanicProgram, + configured: unknown, + maximum = false, ) { - const values = configured && typeof configured === 'object' && !Array.isArray(configured) - ? configured as Record - : {}; - return Object.fromEntries((program.scenarioInputs ?? []).map((input) => [ - input.key, - maximum - ? input.bestCaseValue ?? input.defaultValue ?? (input.kind === 'toggle' ? false : 0) - : values[input.key] ?? input.defaultValue ?? (input.kind === 'toggle' ? false : 0) - ])); + const values = + configured && typeof configured === 'object' && !Array.isArray(configured) + ? (configured as Record) + : {}; + return Object.fromEntries( + (program.scenarioInputs ?? []).map((input) => [ + input.key, + maximum + ? (input.bestCaseValue ?? + input.defaultValue ?? + (input.kind === 'toggle' ? false : 0)) + : (values[input.key] ?? + input.defaultValue ?? + (input.kind === 'toggle' ? false : 0)), + ]), + ); } function evaluateConfiguredMechanic( - program: MechanicProgram, - buildEvents: (scenarioInputs: Record) => MechanicEvent[], - state: any, - profile: LoadoutDraft['combatProfile'], - weaponBaseDamage: number, - maxHealth: number, - calculationMode: LoadoutDraft['calculationMode'], - equipmentContext = { protocolTalentUpgrades: 0 }, - attributes: Record = {} + program: MechanicProgram, + buildEvents: ( + scenarioInputs: Record, + ) => MechanicEvent[], + state: any, + profile: LoadoutDraft['combatProfile'], + weaponBaseDamage: number, + maxHealth: number, + calculationMode: LoadoutDraft['calculationMode'], + equipmentContext = { protocolTalentUpgrades: 0 }, + attributes: Record = {}, ): ActiveMechanicEvaluation | null { - if (calculationMode !== 'maximum' && state.mode === 'off') return null; - const encounterSeconds = Math.max(1, Number(profile.encounterSeconds)); - const maximum = calculationMode === 'maximum' || state.mode === 'peak'; - const scenarioInputs = mechanicScenarioInputs(program, state.inputs, maximum); - const events = buildEvents(scenarioInputs); - const result = evaluateMechanicProgram( - program, - events, - maximum - ? { mode: 'maximum', encounterSeconds } - : { mode: 'custom', encounterSeconds, inputs: scenarioInputs }, - { - maxHealth: Math.max(1, maxHealth), - currentHealth: Math.max(0, maxHealth * Number(profile.healthPercent) / 100), - weaponBaseDamage, - attributes, - values: { extraHealth: profile.extraHealthActive ? 1 : 0 } - }, - equipmentContext - ); - return result.status === 'OK' ? { status: 'OK', result, program, encounterSeconds, events } : null; + if (calculationMode !== 'maximum' && state.mode === 'off') return null; + const encounterSeconds = Math.max(1, Number(profile.encounterSeconds)); + const maximum = calculationMode === 'maximum' || state.mode === 'peak'; + const scenarioInputs = mechanicScenarioInputs(program, state.inputs, maximum); + const events = buildEvents(scenarioInputs); + const result = evaluateMechanicProgram( + program, + events, + maximum + ? { mode: 'maximum', encounterSeconds } + : { mode: 'custom', encounterSeconds, inputs: scenarioInputs }, + { + maxHealth: Math.max(1, maxHealth), + currentHealth: Math.max( + 0, + (maxHealth * Number(profile.healthPercent)) / 100, + ), + weaponBaseDamage, + attributes, + values: { extraHealth: profile.extraHealthActive ? 1 : 0 }, + }, + equipmentContext, + ); + return result.status === 'OK' + ? { status: 'OK', result, program, encounterSeconds, events } + : null; } type ModifierInterval = { - start: number; - end: number; - value: number; - stateValue: number; - attributeId: string; - scope: string; - outcomeId: string; - phase: MechanicOutcome['phase']; - activationEvent: MechanicEvent['kind']; - activationAbilityId: string | null; - endAtNextActivation: boolean; + start: number; + end: number; + value: number; + stateValue: number; + attributeId: string; + scope: string; + outcomeId: string; + phase: MechanicOutcome['phase']; + activationEvent: MechanicEvent['kind']; + activationAbilityId: string | null; + endAtNextActivation: boolean; }; -function modifierIntervals(source: ActiveMechanicEvaluation): ModifierInterval[] { - const emissions = source.result.outcomes - .filter((emission) => emission.kind === 'attribute_modifier' && emission.attributeId) - .sort((left, right) => left.at - right.at); - return emissions.flatMap((emission, index) => { - const clause = source.program.clauses.find((candidate) => candidate.id === emission.clauseId); - const definition = clause?.outcomes?.find((candidate) => candidate.id === emission.outcomeId); - if (!clause || !definition) return []; - const activationAbilityId = definition.phase === 'on_event' && clause.when === 'skill_deployed' - ? emission.targetId - : null; - const nextDeploymentAt = activationAbilityId - ? source.events?.find((event) => - event.kind === 'skill_deployed' - && event.targetId === activationAbilityId - && event.at > emission.at + 0.000_001)?.at - : undefined; - const next = emissions.slice(index + 1).find((candidate) => - candidate.outcomeId === emission.outcomeId - && (activationAbilityId === null || candidate.targetId === activationAbilityId)); - const pool = definition.statePool - ? source.program.pools?.find((candidate) => candidate.key === definition.statePool) - : null; - const deactivatedAt = definition.statePool - ? source.result.emissions.find((candidate) => - candidate.at >= emission.at - && candidate.mutations.some((mutation) => - mutation.pool === definition.statePool - && mutation.after !== undefined - && mutation.after <= 0 - ) - )?.at - : undefined; - const naturalEnd = definition.phase === 'while_active' - ? Math.min( - emission.at + Number(pool?.expiresAfterSeconds ?? source.encounterSeconds), - deactivatedAt ?? source.encounterSeconds - ) - : activationAbilityId - ? nextDeploymentAt ?? source.encounterSeconds - : emission.at; - const end = Math.min(source.encounterSeconds, next?.at ?? naturalEnd, naturalEnd); - if ( - end <= emission.at - && definition.phase !== 'while_active' - && definition.phase !== 'on_event' - ) return []; - return [{ - start: emission.at, - end: Math.max(emission.at, end), - value: emission.appliedValue, - stateValue: emission.stateValue, - attributeId: String(emission.attributeId), - scope: emission.scope, - outcomeId: emission.outcomeId, - phase: definition.phase, - activationEvent: clause.when, - activationAbilityId, - endAtNextActivation: activationAbilityId - ? nextDeploymentAt !== undefined - : next !== undefined && next.at <= naturalEnd - }]; - }); +function modifierIntervals( + source: ActiveMechanicEvaluation, +): ModifierInterval[] { + const emissions = source.result.outcomes + .filter( + (emission) => + emission.kind === 'attribute_modifier' && emission.attributeId, + ) + .sort((left, right) => left.at - right.at); + return emissions.flatMap((emission, index) => { + const clause = source.program.clauses.find( + (candidate) => candidate.id === emission.clauseId, + ); + const definition = clause?.outcomes?.find( + (candidate) => candidate.id === emission.outcomeId, + ); + if (!clause || !definition) return []; + const activationAbilityId = + definition.phase === 'on_event' && clause.when === 'skill_deployed' + ? emission.targetId + : null; + const nextDeploymentAt = activationAbilityId + ? source.events?.find( + (event) => + event.kind === 'skill_deployed' && + event.targetId === activationAbilityId && + event.at > emission.at + 0.000_001, + )?.at + : undefined; + const next = emissions + .slice(index + 1) + .find( + (candidate) => + candidate.outcomeId === emission.outcomeId && + (activationAbilityId === null || + candidate.targetId === activationAbilityId), + ); + const pool = definition.statePool + ? source.program.pools?.find( + (candidate) => candidate.key === definition.statePool, + ) + : null; + const deactivatedAt = definition.statePool + ? source.result.emissions.find( + (candidate) => + candidate.at >= emission.at && + candidate.mutations.some( + (mutation) => + mutation.pool === definition.statePool && + mutation.after !== undefined && + mutation.after <= 0, + ), + )?.at + : undefined; + const naturalEnd = + definition.phase === 'while_active' + ? Math.min( + emission.at + + Number(pool?.expiresAfterSeconds ?? source.encounterSeconds), + deactivatedAt ?? source.encounterSeconds, + ) + : activationAbilityId + ? (nextDeploymentAt ?? source.encounterSeconds) + : emission.at; + const end = Math.min( + source.encounterSeconds, + next?.at ?? naturalEnd, + naturalEnd, + ); + if ( + end <= emission.at && + definition.phase !== 'while_active' && + definition.phase !== 'on_event' + ) + return []; + return [ + { + start: emission.at, + end: Math.max(emission.at, end), + value: emission.appliedValue, + stateValue: emission.stateValue, + attributeId: String(emission.attributeId), + scope: emission.scope, + outcomeId: emission.outcomeId, + phase: definition.phase, + activationEvent: clause.when, + activationAbilityId, + endAtNextActivation: activationAbilityId + ? nextDeploymentAt !== undefined + : next !== undefined && next.at <= naturalEnd, + }, + ]; + }); } function averageModifierValues(source: ActiveMechanicEvaluation) { - const values = new Map(); - for (const interval of modifierIntervals(source)) { - const key = `${interval.attributeId}:${interval.scope}`; - const current = values.get(key) ?? { value: 0, scope: interval.scope }; - current.value += interval.value * (interval.end - interval.start) / source.encounterSeconds; - values.set(key, current); - } - return [...values].map(([key, value]) => ({ - attributeId: key.slice(0, key.lastIndexOf(':')), - scope: value.scope, - value: value.value - })); + const values = new Map(); + for (const interval of modifierIntervals(source)) { + const key = `${interval.attributeId}:${interval.scope}`; + const current = values.get(key) ?? { value: 0, scope: interval.scope }; + current.value += + (interval.value * (interval.end - interval.start)) / + source.encounterSeconds; + values.set(key, current); + } + return [...values].map(([key, value]) => ({ + attributeId: key.slice(0, key.lastIndexOf(':')), + scope: value.scope, + value: value.value, + })); } const TIMELINE_EVENT_ORDER: Readonly> = { - firing_started: 0, - shot_fired: 1, - weapon_hit: 2, - damage_dealt: 3, - firing_stopped: 4, - reload_started: 5, - reload_completed: 6, - weapon_swapped: 7, - enemy_killed: 8, - assist: 9, - ally_downed: 10, - agent_downed: 11, - ally_rescued: 12, - grenade_thrown: 13, - medical_kit_used: 14, - signature_ability_deployed: 15, - skill_deployed: 16, - skill_hit: 17, - skill_multishot_triggered: 18, - agent_hit: 19, - health_damage_taken: 20, - healing_applied: 21, - extra_health_granted: 22, - extra_health_changed: 23, - tick: 24, - proximity_snapshot: 25, - movement_snapshot: 26 + firing_started: 0, + shot_fired: 1, + weapon_hit: 2, + damage_dealt: 3, + firing_stopped: 4, + reload_started: 5, + reload_completed: 6, + weapon_swapped: 7, + enemy_killed: 8, + assist: 9, + ally_downed: 10, + agent_downed: 11, + ally_rescued: 12, + grenade_thrown: 13, + medical_kit_used: 14, + signature_ability_deployed: 15, + skill_deployed: 16, + skill_hit: 17, + skill_multishot_triggered: 18, + agent_hit: 19, + health_damage_taken: 20, + healing_applied: 21, + extra_health_granted: 22, + extra_health_changed: 23, + tick: 24, + proximity_snapshot: 25, + movement_snapshot: 26, }; function intervalAppliesAt( - interval: ModifierInterval, - at: number, - eventKind: MechanicEvent['kind'] + interval: ModifierInterval, + at: number, + eventKind: MechanicEvent['kind'], ) { - const epsilon = 0.000_001; - const queryOrder = TIMELINE_EVENT_ORDER[eventKind]; - const activationOrder = TIMELINE_EVENT_ORDER[interval.activationEvent]; - if (interval.phase === 'on_event') { - return Math.abs(at - interval.start) <= epsilon && activationOrder <= queryOrder; - } - if (interval.phase !== 'while_active') return false; - if (at < interval.start - epsilon || at > interval.end + epsilon) return false; - if (Math.abs(at - interval.start) <= epsilon && activationOrder >= queryOrder) return false; - if ( - Math.abs(at - interval.end) <= epsilon - && !interval.endAtNextActivation - ) { - return false; - } - return true; + const epsilon = 0.000_001; + const queryOrder = TIMELINE_EVENT_ORDER[eventKind]; + const activationOrder = TIMELINE_EVENT_ORDER[interval.activationEvent]; + if (interval.phase === 'on_event') { + return ( + Math.abs(at - interval.start) <= epsilon && activationOrder <= queryOrder + ); + } + if (interval.phase !== 'while_active') return false; + if (at < interval.start - epsilon || at > interval.end + epsilon) + return false; + if (Math.abs(at - interval.start) <= epsilon && activationOrder >= queryOrder) + return false; + if (Math.abs(at - interval.end) <= epsilon && !interval.endAtNextActivation) { + return false; + } + return true; } export function mechanicModifiersAt( - source: ActiveMechanicEvaluation, - at: number, - eventKind: MechanicEvent['kind'] = 'weapon_hit' + source: ActiveMechanicEvaluation, + at: number, + eventKind: MechanicEvent['kind'] = 'weapon_hit', ) { - const values = new Map(); - for (const interval of modifierIntervals(source)) { - if (!intervalAppliesAt(interval, at, eventKind)) continue; - const key = `${interval.attributeId}:${interval.scope}`; - const current = values.get(key) ?? { value: 0, scope: interval.scope }; - current.value += interval.value; - values.set(key, current); - } - return [...values].map(([key, value]) => ({ - attributeId: key.slice(0, key.lastIndexOf(':')), - scope: value.scope, - value: value.value - })); + const values = new Map(); + for (const interval of modifierIntervals(source)) { + if (!intervalAppliesAt(interval, at, eventKind)) continue; + const key = `${interval.attributeId}:${interval.scope}`; + const current = values.get(key) ?? { value: 0, scope: interval.scope }; + current.value += interval.value; + values.set(key, current); + } + return [...values].map(([key, value]) => ({ + attributeId: key.slice(0, key.lastIndexOf(':')), + scope: value.scope, + value: value.value, + })); } function mechanicSummary(source: ActiveMechanicEvaluation) { - const intervals = modifierIntervals(source); - const maxStacks = Math.max(1, ...(source.program.pools ?? []).map((pool) => Number(pool.max ?? 1))); - const maxStacksReached = Math.max(0, ...intervals.map((interval) => interval.stateValue)); - const covered = intervals.reduce((total, interval) => total + Math.max(0, interval.end - interval.start), 0); - const uptimePercent = Math.min(100, covered / source.encounterSeconds * 100); - const buildUp = source.result.outcomes.find((emission) => emission.stateValue >= maxStacks)?.at ?? null; - return { - averageStacks: intervals.reduce( - (total, interval) => total + interval.stateValue * (interval.end - interval.start) / source.encounterSeconds, - 0 - ), - maxStacksReached, - maxStacks, - stacksAtEnd: Math.max(0, ...source.result.finalState.map((state) => state.value)), - buildUpSeconds: buildUp, - maintained: uptimePercent >= 99.9 && maxStacksReached >= maxStacks, - uptimePercent, - activationCount: source.result.estimatedActivationCount, - instantTotal: source.result.outcomes - .filter((outcome) => outcome.kind !== 'direct_damage' && outcome.kind !== 'attribute_modifier') - .reduce((total, outcome) => total + outcome.appliedValue, 0) - }; + const intervals = modifierIntervals(source); + const maxStacks = Math.max( + 1, + ...(source.program.pools ?? []).map((pool) => Number(pool.max ?? 1)), + ); + const maxStacksReached = Math.max( + 0, + ...intervals.map((interval) => interval.stateValue), + ); + const covered = intervals.reduce( + (total, interval) => total + Math.max(0, interval.end - interval.start), + 0, + ); + const uptimePercent = Math.min( + 100, + (covered / source.encounterSeconds) * 100, + ); + const buildUp = + source.result.outcomes.find((emission) => emission.stateValue >= maxStacks) + ?.at ?? null; + return { + averageStacks: intervals.reduce( + (total, interval) => + total + + (interval.stateValue * (interval.end - interval.start)) / + source.encounterSeconds, + 0, + ), + maxStacksReached, + maxStacks, + stacksAtEnd: Math.max( + 0, + ...source.result.finalState.map((state) => state.value), + ), + buildUpSeconds: buildUp, + maintained: uptimePercent >= 99.9 && maxStacksReached >= maxStacks, + uptimePercent, + activationCount: source.result.estimatedActivationCount, + instantTotal: source.result.outcomes + .filter( + (outcome) => + outcome.kind !== 'direct_damage' && + outcome.kind !== 'attribute_modifier', + ) + .reduce((total, outcome) => total + outcome.appliedValue, 0), + }; } export type MechanicTimelineOptions = { - scenarioInputs?: Record; - skillDeploymentEvents?: MechanicEvent[]; + scenarioInputs?: Record; + skillDeploymentEvents?: MechanicEvent[]; }; export function buildWeaponMechanicTimeline( - weapon: any, - profile: LoadoutDraft['combatProfile'], - criticalChancePercent = 0, - options: MechanicTimelineOptions = {} + weapon: any, + profile: LoadoutDraft['combatProfile'], + criticalChancePercent = 0, + options: MechanicTimelineOptions = {}, ): MechanicEvent[] { - const encounterSeconds = Math.max(0, Number(profile.encounterSeconds)); - const rpm = Math.max(1, Number(weapon?.rpm)); - const magazine = Math.max(1, Math.round(Number(weapon?.magazine))); - const reloadSeconds = Math.max(0, Number(weapon?.reload_speed)); - const shotsPerBurst = Math.max(1, Math.round(Number(profile.shotsPerBurst))); - const pauseSeconds = Math.max(0, Number(profile.burstPauseSeconds)); - const targetInterval = Number(profile.distinctTargetsPerMinute) > 0 - ? Math.min( - Math.max(0.1, Number(profile.sameTargetSeconds)), - 60 / Number(profile.distinctTargetsPerMinute) - ) - : Math.max(0.1, Number(profile.sameTargetSeconds)); - const events: MechanicEvent[] = [ - { kind: 'firing_started', at: 0 }, - { - kind: 'extra_health_changed', - at: 0, - actorId: 'agent', - values: { hasExtraHealth: Boolean(profile.extraHealthActive) } - } - ]; - let time = 0; - let magazineRemaining = magazine; - let burstRemaining = shotsPerBurst; - let hitAccumulator = 0; - let headshotAccumulator = 0; - let criticalAccumulator = 0; - const criticalChance = Math.max(0, Math.min(100, Number(criticalChancePercent))) / 100; - const shotInterval = 60 / rpm; - while (time <= encounterSeconds && events.length < 100_000) { - if (magazineRemaining <= 0) { - events.push({ - kind: 'reload_started', - at: time, - values: { emptyMagazine: magazineRemaining === 0 } - }); - time += reloadSeconds; - if (time <= encounterSeconds) { - events.push({ kind: 'reload_completed', at: time }); - events.push({ kind: 'firing_started', at: time }); - } - magazineRemaining = magazine; - burstRemaining = shotsPerBurst; - continue; - } - if (burstRemaining <= 0) { - if (pauseSeconds > 0) { - events.push({ kind: 'firing_stopped', at: time }); - time += pauseSeconds; - if (time <= encounterSeconds) events.push({ kind: 'firing_started', at: time }); - } - burstRemaining = shotsPerBurst; - continue; - } - hitAccumulator += Math.max(0, Math.min(100, Number(profile.hitRate))) / 100; - const hit = hitAccumulator >= 1; - if (hit) hitAccumulator -= 1; - if (hit) headshotAccumulator += Math.max(0, Math.min(100, Number(profile.headshotRate))) / 100; - const headshot = hit && headshotAccumulator >= 1; - if (headshot) headshotAccumulator -= 1; - if (hit) criticalAccumulator += criticalChance; - const critical = hit && criticalAccumulator >= 1; - if (critical) criticalAccumulator -= 1; - const targetId = `target-${Math.floor(time / targetInterval)}`; - const emptiedBeforeShot = magazine - magazineRemaining; - const emptiedAfterShot = emptiedBeforeShot + 1; - const magazineTwentyPercentBandsCrossed = [20, 40, 60, 80].filter((threshold) => - emptiedBeforeShot * 100 < threshold * magazine - && emptiedAfterShot * 100 >= threshold * magazine - ).length; - events.push({ - kind: 'shot_fired', - at: time, - targetId, - values: { - hit, - magazineRemaining, - magazinePercent: magazineRemaining / magazine * 100, - magazineTwentyPercentBandsCrossed, - distanceMeters: Number(profile.targetDistanceMeters) - } - }); - if (hit) { - const values = { - headshot, - critical, - distanceMeters: Number(profile.targetDistanceMeters) - }; - events.push({ kind: 'weapon_hit', at: time, targetId, values }); - events.push({ kind: 'damage_dealt', at: time, targetId, values: { ...values, source: 'weapon' } }); - } - magazineRemaining -= 1; - burstRemaining -= 1; - time += shotInterval; - } - events.push({ kind: 'firing_stopped', at: encounterSeconds }); - const intervalEvents = ( - kind: 'enemy_killed' | 'health_damage_taken' | 'weapon_swapped', - perMinute: number - ) => { - if (!(perMinute > 0)) return; - const interval = 60 / perMinute; - let headshotKillAccumulator = 0; - let criticalKillAccumulator = 0; - for (let at = interval; at <= encounterSeconds; at += interval) { - if (kind === 'enemy_killed') { - headshotKillAccumulator += Math.max(0, Math.min(100, Number(profile.headshotKillRate))) / 100; - const headshot = headshotKillAccumulator >= 1; - if (headshot) headshotKillAccumulator -= 1; - criticalKillAccumulator += criticalChance; - const critical = criticalKillAccumulator >= 1; - if (critical) criticalKillAccumulator -= 1; - events.push({ - kind, - at, - targetId: `target-${Math.floor(at / targetInterval)}`, - values: { - headshot, - critical, - source: 'weapon', - behindCover: options.scenarioInputs?.['behind-cover'] === true, - distanceMeters: Number(profile.targetDistanceMeters) - } - }); - } - else { - events.push(kind === 'health_damage_taken' - ? { - kind, - at, - values: { - distanceMeters: Number(profile.targetDistanceMeters), - recentSkillDeploymentSeconds: Number( - options.scenarioInputs?.['recent-skill-deployment-seconds'] ?? encounterSeconds - ) - } - } - : { kind, at }); - if (kind === 'health_damage_taken') events.push({ kind: 'agent_hit', at }); - } - } - }; - intervalEvents('enemy_killed', Number(profile.killsPerMinute)); - intervalEvents('health_damage_taken', Number(profile.damageTakenPerMinute)); - intervalEvents('weapon_swapped', Number(profile.swapsPerMinute)); - const movementState = options.scenarioInputs?.sprinting === true - ? 'sprinting' - : options.scenarioInputs?.moving === true - ? 'moving' - : 'immobile'; - for (let at = 0; at <= encounterSeconds; at += 1) { - events.push({ kind: 'tick', at }); - events.push({ - kind: 'proximity_snapshot', - at, - values: { - distanceMeters: Number(profile.targetDistanceMeters), - targetDistanceMeters: Number(profile.targetDistanceMeters), - extraHealthActive: Boolean(profile.extraHealthActive), - behindCover: options.scenarioInputs?.['behind-cover'] === true, - enemiesWithinRadius: Number(profile.targetDistanceMeters) <= 20 ? 1 : 0, - enemyCount: Number(profile.targetDistanceMeters) <= 20 ? 1 : 0, - allyCount: Number(options.scenarioInputs?.['allies-within-radius'] ?? 0) - } - }); - events.push({ kind: 'movement_snapshot', at, values: { state: movementState } }); - } - events.push(...(options.skillDeploymentEvents ?? [])); - return events.sort((left, right) => left.at - right.at); + const encounterSeconds = Math.max(0, Number(profile.encounterSeconds)); + const rpm = Math.max(1, Number(weapon?.rpm)); + const magazine = Math.max(1, Math.round(Number(weapon?.magazine))); + const reloadSeconds = Math.max(0, Number(weapon?.reload_speed)); + const shotsPerBurst = Math.max(1, Math.round(Number(profile.shotsPerBurst))); + const pauseSeconds = Math.max(0, Number(profile.burstPauseSeconds)); + const targetInterval = + Number(profile.distinctTargetsPerMinute) > 0 + ? Math.min( + Math.max(0.1, Number(profile.sameTargetSeconds)), + 60 / Number(profile.distinctTargetsPerMinute), + ) + : Math.max(0.1, Number(profile.sameTargetSeconds)); + const events: MechanicEvent[] = [ + { kind: 'firing_started', at: 0 }, + { + kind: 'extra_health_changed', + at: 0, + actorId: 'agent', + values: { hasExtraHealth: Boolean(profile.extraHealthActive) }, + }, + ]; + let time = 0; + let magazineRemaining = magazine; + let burstRemaining = shotsPerBurst; + let hitAccumulator = 0; + let headshotAccumulator = 0; + let criticalAccumulator = 0; + const criticalChance = + Math.max(0, Math.min(100, Number(criticalChancePercent))) / 100; + const shotInterval = 60 / rpm; + while (time <= encounterSeconds && events.length < 100_000) { + if (magazineRemaining <= 0) { + events.push({ + kind: 'reload_started', + at: time, + values: { emptyMagazine: magazineRemaining === 0 }, + }); + time += reloadSeconds; + if (time <= encounterSeconds) { + events.push({ kind: 'reload_completed', at: time }); + events.push({ kind: 'firing_started', at: time }); + } + magazineRemaining = magazine; + burstRemaining = shotsPerBurst; + continue; + } + if (burstRemaining <= 0) { + if (pauseSeconds > 0) { + events.push({ kind: 'firing_stopped', at: time }); + time += pauseSeconds; + if (time <= encounterSeconds) + events.push({ kind: 'firing_started', at: time }); + } + burstRemaining = shotsPerBurst; + continue; + } + hitAccumulator += Math.max(0, Math.min(100, Number(profile.hitRate))) / 100; + const hit = hitAccumulator >= 1; + if (hit) hitAccumulator -= 1; + if (hit) + headshotAccumulator += + Math.max(0, Math.min(100, Number(profile.headshotRate))) / 100; + const headshot = hit && headshotAccumulator >= 1; + if (headshot) headshotAccumulator -= 1; + if (hit) criticalAccumulator += criticalChance; + const critical = hit && criticalAccumulator >= 1; + if (critical) criticalAccumulator -= 1; + const targetId = `target-${Math.floor(time / targetInterval)}`; + const emptiedBeforeShot = magazine - magazineRemaining; + const emptiedAfterShot = emptiedBeforeShot + 1; + const magazineTwentyPercentBandsCrossed = [20, 40, 60, 80].filter( + (threshold) => + emptiedBeforeShot * 100 < threshold * magazine && + emptiedAfterShot * 100 >= threshold * magazine, + ).length; + events.push({ + kind: 'shot_fired', + at: time, + targetId, + values: { + hit, + magazineRemaining, + magazinePercent: (magazineRemaining / magazine) * 100, + magazineTwentyPercentBandsCrossed, + distanceMeters: Number(profile.targetDistanceMeters), + }, + }); + if (hit) { + const values = { + headshot, + critical, + distanceMeters: Number(profile.targetDistanceMeters), + }; + events.push({ kind: 'weapon_hit', at: time, targetId, values }); + events.push({ + kind: 'damage_dealt', + at: time, + targetId, + values: { ...values, source: 'weapon' }, + }); + } + magazineRemaining -= 1; + burstRemaining -= 1; + time += shotInterval; + } + events.push({ kind: 'firing_stopped', at: encounterSeconds }); + const intervalEvents = ( + kind: 'enemy_killed' | 'health_damage_taken' | 'weapon_swapped', + perMinute: number, + ) => { + if (!(perMinute > 0)) return; + const interval = 60 / perMinute; + let headshotKillAccumulator = 0; + let criticalKillAccumulator = 0; + for (let at = interval; at <= encounterSeconds; at += interval) { + if (kind === 'enemy_killed') { + headshotKillAccumulator += + Math.max(0, Math.min(100, Number(profile.headshotKillRate))) / 100; + const headshot = headshotKillAccumulator >= 1; + if (headshot) headshotKillAccumulator -= 1; + criticalKillAccumulator += criticalChance; + const critical = criticalKillAccumulator >= 1; + if (critical) criticalKillAccumulator -= 1; + events.push({ + kind, + at, + targetId: `target-${Math.floor(at / targetInterval)}`, + values: { + headshot, + critical, + source: 'weapon', + behindCover: options.scenarioInputs?.['behind-cover'] === true, + distanceMeters: Number(profile.targetDistanceMeters), + }, + }); + } else { + events.push( + kind === 'health_damage_taken' + ? { + kind, + at, + values: { + distanceMeters: Number(profile.targetDistanceMeters), + recentSkillDeploymentSeconds: Number( + options.scenarioInputs?.[ + 'recent-skill-deployment-seconds' + ] ?? encounterSeconds, + ), + }, + } + : { kind, at }, + ); + if (kind === 'health_damage_taken') + events.push({ kind: 'agent_hit', at }); + } + } + }; + intervalEvents('enemy_killed', Number(profile.killsPerMinute)); + intervalEvents('health_damage_taken', Number(profile.damageTakenPerMinute)); + intervalEvents('weapon_swapped', Number(profile.swapsPerMinute)); + const movementState = + options.scenarioInputs?.sprinting === true + ? 'sprinting' + : options.scenarioInputs?.moving === true + ? 'moving' + : 'immobile'; + for (let at = 0; at <= encounterSeconds; at += 1) { + events.push({ kind: 'tick', at }); + events.push({ + kind: 'proximity_snapshot', + at, + values: { + distanceMeters: Number(profile.targetDistanceMeters), + targetDistanceMeters: Number(profile.targetDistanceMeters), + extraHealthActive: Boolean(profile.extraHealthActive), + behindCover: options.scenarioInputs?.['behind-cover'] === true, + enemiesWithinRadius: Number(profile.targetDistanceMeters) <= 20 ? 1 : 0, + enemyCount: Number(profile.targetDistanceMeters) <= 20 ? 1 : 0, + allyCount: Number( + options.scenarioInputs?.['allies-within-radius'] ?? 0, + ), + }, + }); + events.push({ + kind: 'movement_snapshot', + at, + values: { state: movementState }, + }); + } + events.push(...(options.skillDeploymentEvents ?? [])); + return events.sort((left, right) => left.at - right.at); } -function effectAppliesToWeapon(scope: string, sourceKey: string, weaponIndex: number) { - if (scope === 'allies' || scope === 'assigned_ability' || scope === 'all_skills') return false; - if (scope !== 'equipped_weapon') return true; - const match = sourceKey.match(/^weapon:(\d+):/); - return !match || Number(match[1]) === weaponIndex; +function effectAppliesToWeapon( + scope: string, + sourceKey: string, + weaponIndex: number, +) { + if ( + scope === 'allies' || + scope === 'assigned_ability' || + scope === 'all_skills' + ) + return false; + if (scope !== 'equipped_weapon') return true; + const match = sourceKey.match(/^weapon:(\d+):/); + return !match || Number(match[1]) === weaponIndex; } function effectAppliesToSkills(scope: string) { - return !['allies', 'equipped_weapon'].includes(scope); + return !['allies', 'equipped_weapon'].includes(scope); } function skillCalculationBindings(catalog: Catalog): SkillAttributeBinding[] { - const supportedStages = new Set([ - 'flat_damage', - 'skill_damage', - 'final_damage', - 'critical_chance', - 'critical_damage', - 'multishot_chance', - 'multishot_damage', - 'cooldown_recovery', - 'duration' - ]); - return (catalog.calculation.skillProfiles ?? []).flatMap((profile: any) => { - const stage = profile.stage_key === 'engineering' ? 'skill_damage' : String(profile.stage_key); - return supportedStages.has(stage) - ? [{ - attributeId: String(profile.stat_attribute_id), - stage: stage as SkillAttributeBinding['stage'], - operator: profile.operator === 'multiply' ? 'multiply' as const : 'add' as const, - weight: Number(profile.weight ?? 1) - }] - : []; - }); + const supportedStages = new Set([ + 'flat_damage', + 'skill_damage', + 'final_damage', + 'critical_chance', + 'critical_damage', + 'multishot_chance', + 'multishot_damage', + 'cooldown_recovery', + 'duration', + ]); + return (catalog.calculation.skillProfiles ?? []).flatMap((profile: any) => { + const stage = + profile.stage_key === 'engineering' + ? 'skill_damage' + : String(profile.stage_key); + return supportedStages.has(stage) + ? [ + { + attributeId: String(profile.stat_attribute_id), + stage: stage as SkillAttributeBinding['stage'], + operator: + profile.operator === 'multiply' + ? ('multiply' as const) + : ('add' as const), + weight: Number(profile.weight ?? 1), + }, + ] + : []; + }); } -function loadoutSkillAbilities(draft: LoadoutDraft, catalog: Catalog): SkillAbilityInput[] { - const focus = selectedFocus(draft, catalog); - return (focus?.abilities ?? []).map((ability: any): SkillAbilityInput => { - const attack = ability.attack; - const selected = draft.skillActivations[ability.id] ?? { mode: 'off', targets: 1 }; - const activation = selected.mode === 'custom_per_minute' - ? { mode: 'custom_per_minute' as const, usesPerMinute: Number(selected.usesPerMinute ?? 1) } - : selected.mode === 'on_cooldown' - ? { mode: 'on_cooldown' as const } - : { mode: 'off' as const }; - const chargePolicy = attack?.charge_policy === 'independent' - ? { type: 'independent' as const, charges: Number(attack.charge_count) } - : attack?.charge_policy === 'full_set' - ? { type: 'full_set' as const, charges: Number(attack.charge_count) } - : { type: 'none' as const }; - return { - id: String(ability.id), - baseDamage: attack?.base_damage === undefined ? undefined : Number(attack.base_damage), - damageType: attack?.damage_type, - delivery: attack?.delivery === 'periodic' - ? { - type: 'periodic' as const, - ticks: Math.max(1, Math.floor(Number(attack.damage_duration_seconds) / Number(attack.tick_interval_seconds))), - durationSeconds: Number(attack.damage_duration_seconds), - hitsPerTick: Number(attack.hits_per_activation ?? 1), - intervalSeconds: Number(attack.tick_interval_seconds), - firstTickDelaySeconds: Number(attack.tick_interval_seconds) - } - : { type: 'direct' as const, hits: Number(attack?.hits_per_activation ?? 1) }, - castSeconds: Number(attack?.cast_seconds ?? 0), - cooldownSeconds: Number(ability.cooldown_seconds ?? 0), - targetCap: Number(attack?.target_cap ?? 1), - targetCount: Number(selected.targets ?? 1), - chargePolicy, - activation - }; - }); +function loadoutSkillAbilities( + draft: LoadoutDraft, + catalog: Catalog, +): SkillAbilityInput[] { + const focus = selectedFocus(draft, catalog); + return (focus?.abilities ?? []).map((ability: any): SkillAbilityInput => { + const attack = ability.attack; + const selected = draft.skillActivations[ability.id] ?? { + mode: 'off', + targets: 1, + }; + const activation = + selected.mode === 'custom_per_minute' + ? { + mode: 'custom_per_minute' as const, + usesPerMinute: Number(selected.usesPerMinute ?? 1), + } + : selected.mode === 'on_cooldown' + ? { mode: 'on_cooldown' as const } + : { mode: 'off' as const }; + const chargePolicy = + attack?.charge_policy === 'independent' + ? { type: 'independent' as const, charges: Number(attack.charge_count) } + : attack?.charge_policy === 'full_set' + ? { type: 'full_set' as const, charges: Number(attack.charge_count) } + : { type: 'none' as const }; + return { + id: String(ability.id), + baseDamage: + attack?.base_damage === undefined + ? undefined + : Number(attack.base_damage), + damageType: attack?.damage_type, + delivery: + attack?.delivery === 'periodic' + ? { + type: 'periodic' as const, + ticks: Math.max( + 1, + Math.floor( + Number(attack.damage_duration_seconds) / + Number(attack.tick_interval_seconds), + ), + ), + durationSeconds: Number(attack.damage_duration_seconds), + hitsPerTick: Number(attack.hits_per_activation ?? 1), + intervalSeconds: Number(attack.tick_interval_seconds), + firstTickDelaySeconds: Number(attack.tick_interval_seconds), + } + : { + type: 'direct' as const, + hits: Number(attack?.hits_per_activation ?? 1), + }, + castSeconds: Number(attack?.cast_seconds ?? 0), + cooldownSeconds: Number(ability.cooldown_seconds ?? 0), + targetCap: Number(attack?.target_cap ?? 1), + targetCount: Number(selected.targets ?? 1), + chargePolicy, + activation, + }; + }); } export function buildSkillDeploymentEvents( - abilities: SkillAbilityInput[], - encounterSeconds: number, - rules: SkillCalculationRules = { revisionId: 'mechanic-timeline', bindings: [] }, - attributes: Record = {} + abilities: SkillAbilityInput[], + encounterSeconds: number, + rules: SkillCalculationRules = { + revisionId: 'mechanic-timeline', + bindings: [], + }, + attributes: Record = {}, ): MechanicEvent[] { - const input: SkillCalculationInput = { - rules, - abilities, - attributes, - assumptions: { encounterSeconds, targetCount: 1 } - }; - return abilities.flatMap((ability) => { - if ( - ability.activation.mode === 'off' - || ( - ability.activation.mode === 'on_cooldown' - && Number(ability.castSeconds) <= 0 - && Number(ability.cooldownSeconds) <= 0 - ) - ) return []; - return skillActivationTimes(ability, encounterSeconds, input, rules).map((at): MechanicEvent => ({ - kind: 'skill_deployed', - at, - targetId: ability.id, - values: { abilityId: ability.id } - })); - }).sort((left, right) => left.at - right.at); + const input: SkillCalculationInput = { + rules, + abilities, + attributes, + assumptions: { encounterSeconds, targetCount: 1 }, + }; + return abilities + .flatMap((ability) => { + if ( + ability.activation.mode === 'off' || + (ability.activation.mode === 'on_cooldown' && + Number(ability.castSeconds) <= 0 && + Number(ability.cooldownSeconds) <= 0) + ) + return []; + return skillActivationTimes(ability, encounterSeconds, input, rules).map( + (at): MechanicEvent => ({ + kind: 'skill_deployed', + at, + targetId: ability.id, + values: { abilityId: ability.id }, + }), + ); + }) + .sort((left, right) => left.at - right.at); } function loadoutSkillDeploymentEvents( - draft: LoadoutDraft, - catalog: Catalog, - summary: LoadoutSummary + draft: LoadoutDraft, + catalog: Catalog, + summary: LoadoutSummary, ) { - const bindings = skillCalculationBindings(catalog); - const boundIds = new Set(bindings.map((binding) => binding.attributeId)); - const attributes = Object.fromEntries( - summary.stats.filter((stat) => boundIds.has(stat.id)).map((stat) => [stat.id, stat.value]) - ); - return buildSkillDeploymentEvents( - loadoutSkillAbilities(draft, catalog), - Math.max(1, Number(draft.combatProfile.encounterSeconds)), - { revisionId: String(catalog.calculation.revision?.id ?? 'mechanic-timeline'), bindings }, - attributes - ); + const bindings = skillCalculationBindings(catalog); + const boundIds = new Set(bindings.map((binding) => binding.attributeId)); + const attributes = Object.fromEntries( + summary.stats + .filter((stat) => boundIds.has(stat.id)) + .map((stat) => [stat.id, stat.value]), + ); + return buildSkillDeploymentEvents( + loadoutSkillAbilities(draft, catalog), + Math.max(1, Number(draft.combatProfile.encounterSeconds)), + { + revisionId: String( + catalog.calculation.revision?.id ?? 'mechanic-timeline', + ), + bindings, + }, + attributes, + ); } export function skillModifierWindowsForMechanic( - source: ActiveMechanicEvaluation, - sourceAbilityId: string | null, - focusAbilityIds: ReadonlySet, - allowedAttributeIds: ReadonlySet + source: ActiveMechanicEvaluation, + sourceAbilityId: string | null, + focusAbilityIds: ReadonlySet, + allowedAttributeIds: ReadonlySet, ): SkillModifierWindow[] { - return modifierIntervals(source).flatMap((interval) => { - if ( - !effectAppliesToSkills(interval.scope) - || !allowedAttributeIds.has(interval.attributeId) - || interval.end <= interval.start - ) return []; - const abilityIds = interval.activationAbilityId - ? (focusAbilityIds.has(interval.activationAbilityId) ? [interval.activationAbilityId] : []) - : interval.scope === 'assigned_ability' - ? (sourceAbilityId && focusAbilityIds.has(sourceAbilityId) ? [sourceAbilityId] : []) - : undefined; - if ((interval.activationAbilityId || interval.scope === 'assigned_ability') && !abilityIds?.length) { - return []; - } - return [{ - startSeconds: interval.start, - endSeconds: interval.end, - attributes: { [interval.attributeId]: interval.value }, - abilityIds - }]; - }); + return modifierIntervals(source).flatMap((interval) => { + if ( + !effectAppliesToSkills(interval.scope) || + !allowedAttributeIds.has(interval.attributeId) || + interval.end <= interval.start + ) + return []; + const abilityIds = interval.activationAbilityId + ? focusAbilityIds.has(interval.activationAbilityId) + ? [interval.activationAbilityId] + : [] + : interval.scope === 'assigned_ability' + ? sourceAbilityId && focusAbilityIds.has(sourceAbilityId) + ? [sourceAbilityId] + : [] + : undefined; + if ( + (interval.activationAbilityId || interval.scope === 'assigned_ability') && + !abilityIds?.length + ) { + return []; + } + return [ + { + startSeconds: interval.start, + endSeconds: interval.end, + attributes: { [interval.attributeId]: interval.value }, + abilityIds, + }, + ]; + }); } function skillModifierWindows( - draft: LoadoutDraft, - catalog: Catalog, - summary: LoadoutSummary, - allowedAttributeIds: Set + draft: LoadoutDraft, + catalog: Catalog, + summary: LoadoutSummary, + allowedAttributeIds: Set, ): SkillModifierWindow[] { - const focus = selectedFocus(draft, catalog); - const combatEffects = selectedNodeRanks(draft, catalog).flatMap(({ node, rank }: any) => - (rank?.combatEffects ?? []).map((effect: any) => ({ - ...effect, - nodeName: node.name, - abilityId: null - })) - ); - const sources = [ - ...summary.equippedTalents.map((equipped: any) => ({ - key: String(equipped.key), - abilityId: equipped.abilityId ? String(equipped.abilityId) : null, - equipmentContext: { - protocolTalentUpgrades: String(equipped.key).startsWith('protocol:') - ? Number(equipped.tier) - : 0 - }, - program: equippedConditionalProgram(equipped) - })), - ...combatEffects.map((effect: any) => ({ - key: String(effect.id), - abilityId: effect.abilityId, - equipmentContext: { protocolTalentUpgrades: 0 }, - program: conditionalProgram(effect.rule, 'base') - })) - ].filter((source) => source.program); - const primarySelection = draft.weapons[0]; - const primaryWeapon = catalog.weapons.find((weapon) => weapon.id === primarySelection.itemId); - const primaryBindings = calculationBindings(catalog, primaryWeapon); - const baseAttributes = Object.fromEntries(summary.stats.map((stat) => [stat.id, stat.value])); - const primaryCriticalChance = weaponCriticalChance(baseAttributes, primaryBindings); - const focusAbilityIds = new Set( - (focus?.abilities ?? []).map((ability: any) => String(ability.id)) - ); - const windows: SkillModifierWindow[] = []; - const maxHealthId = catalog.attributes.find((item) => item.name === 'Health')?.id; - const maxHealth = summary.stats.find((stat) => stat.id === maxHealthId)?.value ?? 1; - const skillDeploymentEvents = loadoutSkillDeploymentEvents(draft, catalog, summary); - const evaluatedSources = sources.flatMap((source) => { - const state = draft.conditionalEffects[source.key] ?? { mode: 'off' }; - const profile = { ...draft.combatProfile, ...(state.overrides ?? {}) }; - const evaluation = evaluateConfiguredMechanic( - source.program!, - (scenarioInputs) => primaryWeapon - ? buildWeaponMechanicTimeline(primaryWeapon, profile, primaryCriticalChance, { - scenarioInputs, - skillDeploymentEvents - }) - : skillDeploymentEvents, - state, - profile, - Number(primarySelection.damage ?? 0), - maxHealth, - draft.calculationMode, - source.equipmentContext, - baseAttributes - ); - return evaluation ? [{ ...source, evaluation }] : []; - }); + const focus = selectedFocus(draft, catalog); + const combatEffects = selectedNodeRanks(draft, catalog).flatMap( + ({ node, rank }: any) => + (rank?.combatEffects ?? []).map((effect: any) => ({ + ...effect, + nodeName: node.name, + abilityId: null, + })), + ); + const sources = [ + ...summary.equippedTalents.map((equipped: any) => ({ + key: String(equipped.key), + abilityId: equipped.abilityId ? String(equipped.abilityId) : null, + equipmentContext: { + protocolTalentUpgrades: String(equipped.key).startsWith('protocol:') + ? Number(equipped.tier) + : 0, + }, + program: equippedConditionalProgram(equipped), + })), + ...combatEffects.map((effect: any) => ({ + key: String(effect.id), + abilityId: effect.abilityId, + equipmentContext: { protocolTalentUpgrades: 0 }, + program: conditionalProgram(effect.rule, 'base'), + })), + ].filter((source) => source.program); + const primarySelection = draft.weapons[0]; + const primaryWeapon = catalog.weapons.find( + (weapon) => weapon.id === primarySelection.itemId, + ); + const primaryBindings = calculationBindings(catalog, primaryWeapon); + const baseAttributes = Object.fromEntries( + summary.stats.map((stat) => [stat.id, stat.value]), + ); + const primaryCriticalChance = weaponCriticalChance( + baseAttributes, + primaryBindings, + ); + const focusAbilityIds = new Set( + (focus?.abilities ?? []).map((ability: any) => String(ability.id)), + ); + const windows: SkillModifierWindow[] = []; + const maxHealthId = catalog.attributes.find( + (item) => item.name === 'Health', + )?.id; + const maxHealth = + summary.stats.find((stat) => stat.id === maxHealthId)?.value ?? 1; + const skillDeploymentEvents = loadoutSkillDeploymentEvents( + draft, + catalog, + summary, + ); + const evaluatedSources = sources.flatMap((source) => { + const state = draft.conditionalEffects[source.key] ?? { mode: 'off' }; + const profile = { ...draft.combatProfile, ...(state.overrides ?? {}) }; + const evaluation = evaluateConfiguredMechanic( + source.program!, + (scenarioInputs) => + primaryWeapon + ? buildWeaponMechanicTimeline( + primaryWeapon, + profile, + primaryCriticalChance, + { + scenarioInputs, + skillDeploymentEvents, + }, + ) + : skillDeploymentEvents, + state, + profile, + Number(primarySelection.damage ?? 0), + maxHealth, + draft.calculationMode, + source.equipmentContext, + baseAttributes, + ); + return evaluation ? [{ ...source, evaluation }] : []; + }); - for (const source of evaluatedSources) { - windows.push(...skillModifierWindowsForMechanic( - source.evaluation, - source.abilityId, - focusAbilityIds, - allowedAttributeIds - )); - } + for (const source of evaluatedSources) { + windows.push( + ...skillModifierWindowsForMechanic( + source.evaluation, + source.abilityId, + focusAbilityIds, + allowedAttributeIds, + ), + ); + } - return windows; + return windows; } export function evaluateLoadoutCombat( - draft: LoadoutDraft, - catalog: Catalog, - summary = summarizeLoadout(draft, catalog) + draft: LoadoutDraft, + catalog: Catalog, + summary = summarizeLoadout(draft, catalog), ): WeaponCalculationResult[] { - const focus = selectedFocus(draft, catalog); - const combatEffects = selectedNodeRanks(draft, catalog).flatMap(({ node, rank }: any) => - (rank?.combatEffects ?? []).map((effect: any) => ({ ...effect, nodeName: node.name })) - ); - const conditionalSources = [ - ...summary.equippedTalents.map((equipped: any) => ({ - key: equipped.key, - name: equipped.custom?.name ?? catalog.talents.find((talent) => talent.id === equipped.id)?.name ?? 'Talent', - equipmentContext: { - protocolTalentUpgrades: String(equipped.key).startsWith('protocol:') - ? Number(equipped.tier) - : 0 - }, - program: equippedConditionalProgram(equipped) - })), - ...combatEffects.map((effect: any) => ({ - key: String(effect.id), - name: String(effect.definition?.name ?? effect.nodeName ?? 'Specialization effect'), - equipmentContext: { protocolTalentUpgrades: 0 }, - program: conditionalProgram(effect.rule, 'base') - })) - ].filter((source) => source.program); - const skillDeploymentEvents = loadoutSkillDeploymentEvents(draft, catalog, summary); - const required = (weaponIndex: number) => { - const missing: string[] = []; - if (!catalog.calculation.revision) missing.push('An active ISAC calculation revision'); - else if (!catalog.calculation.profiles.length) missing.push('Published attribute calculation profiles'); - if (!draft.weapons[weaponIndex].itemId) missing.push(`${weaponIndex ? 'Secondary' : 'Primary'} weapon`); - for (const slot of gearSlots) if (!draft.gear[slot].itemId) missing.push(`${slot} gear`); - if (!draft.protocol.itemId) missing.push('OS Protocol'); - if (!draft.specialization.focusId) missing.push('Specialization focus'); - else if ((focus?.abilities.length ?? 0) !== 3) missing.push('Three configured focus abilities'); - const missingChips = draft.chips.filter((chip) => !chip.setId).length; - if (missingChips) missing.push(`${missingChips} specialization Skill Chip${missingChips === 1 ? '' : 's'}`); - return missing; - }; + const focus = selectedFocus(draft, catalog); + const combatEffects = selectedNodeRanks(draft, catalog).flatMap( + ({ node, rank }: any) => + (rank?.combatEffects ?? []).map((effect: any) => ({ + ...effect, + nodeName: node.name, + })), + ); + const conditionalSources = [ + ...summary.equippedTalents.map((equipped: any) => ({ + key: equipped.key, + name: + equipped.custom?.name ?? + catalog.talents.find((talent) => talent.id === equipped.id)?.name ?? + 'Talent', + equipmentContext: { + protocolTalentUpgrades: String(equipped.key).startsWith('protocol:') + ? Number(equipped.tier) + : 0, + }, + program: equippedConditionalProgram(equipped), + })), + ...combatEffects.map((effect: any) => ({ + key: String(effect.id), + name: String( + effect.definition?.name ?? effect.nodeName ?? 'Specialization effect', + ), + equipmentContext: { protocolTalentUpgrades: 0 }, + program: conditionalProgram(effect.rule, 'base'), + })), + ].filter((source) => source.program); + const skillDeploymentEvents = loadoutSkillDeploymentEvents( + draft, + catalog, + summary, + ); + const required = (weaponIndex: number) => { + const missing: string[] = []; + if (!catalog.calculation.revision) + missing.push('An active ISAC calculation revision'); + else if (!catalog.calculation.profiles.length) + missing.push('Published attribute calculation profiles'); + if (!draft.weapons[weaponIndex].itemId) + missing.push(`${weaponIndex ? 'Secondary' : 'Primary'} weapon`); + for (const slot of gearSlots) + if (!draft.gear[slot].itemId) missing.push(`${slot} gear`); + if (!draft.protocol.itemId) missing.push('OS Protocol'); + if (!draft.specialization.focusId) missing.push('Specialization focus'); + else if ((focus?.abilities.length ?? 0) !== 3) + missing.push('Three configured focus abilities'); + const missingChips = draft.chips.filter((chip) => !chip.setId).length; + if (missingChips) + missing.push( + `${missingChips} specialization Skill Chip${missingChips === 1 ? '' : 's'}`, + ); + return missing; + }; - return [0, 1].map((weaponIndex) => { - const selection = draft.weapons[weaponIndex]; - const weapon = catalog.weapons.find((item) => item.id === selection.itemId); - const missing = required(weaponIndex); - if (missing.length) { - return { - weaponName: weapon?.name ?? `${weaponIndex ? 'Secondary' : 'Primary'} weapon`, - weaponLabel: weaponIndex ? 'SECONDARY WEAPON' : 'PRIMARY WEAPON', - status: 'insufficient', - missing, - revisionLabel: catalog.calculation.revision - ? `RULES R${catalog.calculation.revision.version}` - : undefined - }; - } - const bindings = calculationBindings(catalog, weapon); - const baseAttributes = Object.fromEntries(summary.stats.map((stat) => [stat.id, stat.value])); - const maxHealthId = catalog.attributes.find((item) => item.name === 'Health')?.id; - const maxHealth = Number(baseAttributes[maxHealthId] ?? 1); - const evaluateSources = ( - timelineWeapon: any, - criticalAttributes: Record - ) => conditionalSources.flatMap((source) => { - const state = draft.conditionalEffects[source.key] ?? { mode: 'off' }; - const profile = { ...draft.combatProfile, ...(state.overrides ?? {}) }; - const evaluation = evaluateConfiguredMechanic( - source.program!, - (scenarioInputs) => buildWeaponMechanicTimeline( - timelineWeapon, - profile, - weaponCriticalChance(criticalAttributes, bindings), - { scenarioInputs, skillDeploymentEvents } - ), - state, - profile, - Number(selection.damage), - maxHealth, - draft.calculationMode, - source.equipmentContext, - criticalAttributes - ); - return evaluation ? [{ ...source, state, evaluation }] : []; - }); - const attributesFrom = (evaluations: ReturnType) => { - const values = { ...baseAttributes }; - for (const source of evaluations) { - for (const modifier of averageModifierValues(source.evaluation)) { - if (!effectAppliesToWeapon(modifier.scope, source.key, weaponIndex)) continue; - values[modifier.attributeId] = applyAttributeValue( - catalog, - values[modifier.attributeId] ?? 0, - modifier.attributeId, - modifier.value - ); - } - } - return values; - }; - let timelineWeapon = { ...weapon }; - let evaluations = evaluateSources(timelineWeapon, baseAttributes); - for (let pass = 0; pass < 3; pass += 1) { - const currentAttributes = attributesFrom(evaluations); - const cycle = resolveWeaponFiringCycle( - { - rpm: Number(weapon?.rpm), - magazine: Number(weapon?.magazine), - reloadSeconds: Number(weapon?.reload_speed) - }, - currentAttributes, - bindings - ); - timelineWeapon = { - ...weapon, - rpm: cycle.rpm, - magazine: cycle.magazine, - reload_speed: cycle.reloadSeconds - }; - evaluations = evaluateSources(timelineWeapon, currentAttributes); - } - const modeledAttributes = attributesFrom(evaluations); - let directDamageTotal = 0; - for (const source of evaluations) { - for (const outcome of source.evaluation.result.outcomes) { - if (outcome.kind !== 'direct_damage' - || !effectAppliesToWeapon(outcome.scope, source.key, weaponIndex)) continue; - directDamageTotal += outcome.appliedValue; - } - } - const calculationStages = catalog.calculation.stages - .map((stage) => calculationStage(catalog, String(stage.stage_key))) - .filter((stage): stage is CalculationStage => stage !== null); - const result = calculateCombat({ - weapon: { - baseDamage: selection.damage, - rpm: Number(weapon?.rpm), - magazine: Number(weapon?.magazine), - reloadSeconds: Number(weapon?.reload_speed), - headshotMultiplier: 2 - }, - attributes: modeledAttributes, - bindings, - stageOrder: calculationStages, - assumptions: { - mode: 'off', - hitRate: draft.combatProfile.hitRate, - headshotRate: draft.combatProfile.headshotRate, - encounterSeconds: draft.combatProfile.encounterSeconds, - firingActivity: 100, - } - }); - if (result.status === 'INSUFFICIENT_DATA') { - return { - weaponName: weapon?.name ?? `${weaponIndex ? 'Secondary' : 'Primary'} weapon`, - weaponLabel: weaponIndex ? 'SECONDARY WEAPON' : 'PRIMARY WEAPON', - status: 'insufficient', - missing: result.missing.map((item) => - item.startsWith('binding:') - ? `Calculation profile for ${attribute(catalog, item.slice(8))?.name ?? item.slice(8)}` - : item.replaceAll('.', ' ') - ), - revisionLabel: `RULES R${catalog.calculation.revision.version}` - }; - } - const timeline = buildWeaponMechanicTimeline( - timelineWeapon, - draft.combatProfile, - weaponCriticalChance(modeledAttributes, bindings) - ); - let encounterWeaponDamage = 0; - for (const event of timeline) { - if (event.kind !== 'weapon_hit') continue; - const attributesAtShot = { ...baseAttributes }; - for (const source of evaluations) { - for (const modifier of mechanicModifiersAt(source.evaluation, event.at, event.kind)) { - if (!effectAppliesToWeapon(modifier.scope, source.key, weaponIndex)) continue; - attributesAtShot[modifier.attributeId] = applyAttributeValue( - catalog, - attributesAtShot[modifier.attributeId] ?? 0, - modifier.attributeId, - modifier.value - ); - } - } - encounterWeaponDamage += expectedWeaponShotDamage( - { - baseDamage: selection.damage, - rpm: Number(weapon?.rpm), - magazine: Number(weapon?.magazine), - reloadSeconds: Number(weapon?.reload_speed), - headshotMultiplier: 2 - }, - attributesAtShot, - bindings, - event.values?.headshot === true, - calculationStages - ); - } - return { - weaponName: weapon.name, - weaponLabel: weaponIndex ? 'SECONDARY WEAPON' : 'PRIMARY WEAPON', - status: 'complete', - bodyHit: result.hitValues?.normal, - criticalBodyHit: result.hitValues?.critical, - headshot: result.hitValues?.headshot, - criticalHeadshot: result.hitValues?.criticalHeadshot, - burstDps: result.burstDps ?? undefined, - sustainedDps: result.sustainedDps ?? undefined, - encounterDps: (encounterWeaponDamage + directDamageTotal) - / Math.max(1, draft.combatProfile.encounterSeconds), - effectSummaries: evaluations.map((source) => ({ - id: source.key, - name: source.name, - mode: source.state.mode, - ...mechanicSummary(source.evaluation) - })), - revisionLabel: `RULES R${catalog.calculation.revision.version}` - }; - }); + return [0, 1].map((weaponIndex) => { + const selection = draft.weapons[weaponIndex]; + const weapon = catalog.weapons.find((item) => item.id === selection.itemId); + const missing = required(weaponIndex); + if (missing.length) { + return { + weaponName: + weapon?.name ?? `${weaponIndex ? 'Secondary' : 'Primary'} weapon`, + weaponLabel: weaponIndex ? 'SECONDARY WEAPON' : 'PRIMARY WEAPON', + status: 'insufficient', + missing, + revisionLabel: catalog.calculation.revision + ? `RULES R${catalog.calculation.revision.version}` + : undefined, + }; + } + const bindings = calculationBindings(catalog, weapon); + const baseAttributes = Object.fromEntries( + summary.stats.map((stat) => [stat.id, stat.value]), + ); + const maxHealthId = catalog.attributes.find( + (item) => item.name === 'Health', + )?.id; + const maxHealth = Number(baseAttributes[maxHealthId] ?? 1); + const evaluateSources = ( + timelineWeapon: any, + criticalAttributes: Record, + ) => + conditionalSources.flatMap((source) => { + const state = draft.conditionalEffects[source.key] ?? { mode: 'off' }; + const profile = { ...draft.combatProfile, ...(state.overrides ?? {}) }; + const evaluation = evaluateConfiguredMechanic( + source.program!, + (scenarioInputs) => + buildWeaponMechanicTimeline( + timelineWeapon, + profile, + weaponCriticalChance(criticalAttributes, bindings), + { scenarioInputs, skillDeploymentEvents }, + ), + state, + profile, + Number(selection.damage), + maxHealth, + draft.calculationMode, + source.equipmentContext, + criticalAttributes, + ); + return evaluation ? [{ ...source, state, evaluation }] : []; + }); + const attributesFrom = ( + evaluations: ReturnType, + ) => { + const values = { ...baseAttributes }; + for (const source of evaluations) { + for (const modifier of averageModifierValues(source.evaluation)) { + if (!effectAppliesToWeapon(modifier.scope, source.key, weaponIndex)) + continue; + values[modifier.attributeId] = applyAttributeValue( + catalog, + values[modifier.attributeId] ?? 0, + modifier.attributeId, + modifier.value, + ); + } + } + return values; + }; + let timelineWeapon = { ...weapon }; + let evaluations = evaluateSources(timelineWeapon, baseAttributes); + for (let pass = 0; pass < 3; pass += 1) { + const currentAttributes = attributesFrom(evaluations); + const cycle = resolveWeaponFiringCycle( + { + rpm: Number(weapon?.rpm), + magazine: Number(weapon?.magazine), + reloadSeconds: Number(weapon?.reload_speed), + }, + currentAttributes, + bindings, + ); + timelineWeapon = { + ...weapon, + rpm: cycle.rpm, + magazine: cycle.magazine, + reload_speed: cycle.reloadSeconds, + }; + evaluations = evaluateSources(timelineWeapon, currentAttributes); + } + const modeledAttributes = attributesFrom(evaluations); + let directDamageTotal = 0; + for (const source of evaluations) { + for (const outcome of source.evaluation.result.outcomes) { + if ( + outcome.kind !== 'direct_damage' || + !effectAppliesToWeapon(outcome.scope, source.key, weaponIndex) + ) + continue; + directDamageTotal += outcome.appliedValue; + } + } + const calculationStages = catalog.calculation.stages + .map((stage) => calculationStage(catalog, String(stage.stage_key))) + .filter((stage): stage is CalculationStage => stage !== null); + const result = calculateCombat({ + weapon: { + baseDamage: selection.damage, + rpm: Number(weapon?.rpm), + magazine: Number(weapon?.magazine), + reloadSeconds: Number(weapon?.reload_speed), + headshotMultiplier: 2, + }, + attributes: modeledAttributes, + bindings, + stageOrder: calculationStages, + assumptions: { + mode: 'off', + hitRate: draft.combatProfile.hitRate, + headshotRate: draft.combatProfile.headshotRate, + encounterSeconds: draft.combatProfile.encounterSeconds, + firingActivity: 100, + }, + }); + if (result.status === 'INSUFFICIENT_DATA') { + return { + weaponName: + weapon?.name ?? `${weaponIndex ? 'Secondary' : 'Primary'} weapon`, + weaponLabel: weaponIndex ? 'SECONDARY WEAPON' : 'PRIMARY WEAPON', + status: 'insufficient', + missing: result.missing.map((item) => + item.startsWith('binding:') + ? `Calculation profile for ${attribute(catalog, item.slice(8))?.name ?? item.slice(8)}` + : item.replaceAll('.', ' '), + ), + revisionLabel: `RULES R${catalog.calculation.revision.version}`, + }; + } + const timeline = buildWeaponMechanicTimeline( + timelineWeapon, + draft.combatProfile, + weaponCriticalChance(modeledAttributes, bindings), + ); + let encounterWeaponDamage = 0; + for (const event of timeline) { + if (event.kind !== 'weapon_hit') continue; + const attributesAtShot = { ...baseAttributes }; + for (const source of evaluations) { + for (const modifier of mechanicModifiersAt( + source.evaluation, + event.at, + event.kind, + )) { + if (!effectAppliesToWeapon(modifier.scope, source.key, weaponIndex)) + continue; + attributesAtShot[modifier.attributeId] = applyAttributeValue( + catalog, + attributesAtShot[modifier.attributeId] ?? 0, + modifier.attributeId, + modifier.value, + ); + } + } + encounterWeaponDamage += expectedWeaponShotDamage( + { + baseDamage: selection.damage, + rpm: Number(weapon?.rpm), + magazine: Number(weapon?.magazine), + reloadSeconds: Number(weapon?.reload_speed), + headshotMultiplier: 2, + }, + attributesAtShot, + bindings, + event.values?.headshot === true, + calculationStages, + ); + } + return { + weaponName: weapon.name, + weaponLabel: weaponIndex ? 'SECONDARY WEAPON' : 'PRIMARY WEAPON', + status: 'complete', + bodyHit: result.hitValues?.normal, + criticalBodyHit: result.hitValues?.critical, + headshot: result.hitValues?.headshot, + criticalHeadshot: result.hitValues?.criticalHeadshot, + burstDps: result.burstDps ?? undefined, + sustainedDps: result.sustainedDps ?? undefined, + encounterDps: + (encounterWeaponDamage + directDamageTotal) / + Math.max(1, draft.combatProfile.encounterSeconds), + effectSummaries: evaluations.map((source) => ({ + id: source.key, + name: source.name, + mode: source.state.mode, + ...mechanicSummary(source.evaluation), + })), + revisionLabel: `RULES R${catalog.calculation.revision.version}`, + }; + }); } export function evaluateLoadoutSkillCombat( - draft: LoadoutDraft, - catalog: Catalog, - summary = summarizeLoadout(draft, catalog) + draft: LoadoutDraft, + catalog: Catalog, + summary = summarizeLoadout(draft, catalog), ): SkillCalculation { - const revisionId = String(catalog.calculation.revision?.id ?? ''); - const bindings = skillCalculationBindings(catalog); - const boundIds = new Set(bindings.map((binding) => binding.attributeId)); - const attributes = Object.fromEntries( - summary.stats.filter((stat) => boundIds.has(stat.id)).map((stat) => [stat.id, stat.value]) - ); - const abilities = loadoutSkillAbilities(draft, catalog); - return calculateSkillDps({ - rules: { revisionId, bindings }, - abilities, - attributes, - attributeRules: Object.fromEntries([...boundIds].map((id) => { - const stat = attribute(catalog, id); - return [id, { - combination: stat?.calculation_type === 'Multiplicative' && stat?.unit === '%' - ? 'multiplicative_percent' as const - : 'additive' as const, - cap: stat?.cap_value === null || stat?.cap_value === undefined - ? null - : Number(stat.cap_value) - }]; - })), - modifierWindows: skillModifierWindows(draft, catalog, summary, boundIds), - assumptions: { - encounterSeconds: draft.combatProfile.encounterSeconds, - targetCount: Math.max(1, ...Object.values(draft.skillActivations).map((selection) => Number(selection.targets ?? 1))) - } - }); + const revisionId = String(catalog.calculation.revision?.id ?? ''); + const bindings = skillCalculationBindings(catalog); + const boundIds = new Set(bindings.map((binding) => binding.attributeId)); + const attributes = Object.fromEntries( + summary.stats + .filter((stat) => boundIds.has(stat.id)) + .map((stat) => [stat.id, stat.value]), + ); + const abilities = loadoutSkillAbilities(draft, catalog); + return calculateSkillDps({ + rules: { revisionId, bindings }, + abilities, + attributes, + attributeRules: Object.fromEntries( + [...boundIds].map((id) => { + const stat = attribute(catalog, id); + return [ + id, + { + combination: + stat?.calculation_type === 'Multiplicative' && stat?.unit === '%' + ? ('multiplicative_percent' as const) + : ('additive' as const), + cap: + stat?.cap_value === null || stat?.cap_value === undefined + ? null + : Number(stat.cap_value), + }, + ]; + }), + ), + modifierWindows: skillModifierWindows(draft, catalog, summary, boundIds), + assumptions: { + encounterSeconds: draft.combatProfile.encounterSeconds, + targetCount: Math.max( + 1, + ...Object.values(draft.skillActivations).map((selection) => + Number(selection.targets ?? 1), + ), + ), + }, + }); } diff --git a/src/lib/builder/loadout.test.ts b/src/lib/builder/loadout.test.ts index cbfd852..dd659a8 100644 --- a/src/lib/builder/loadout.test.ts +++ b/src/lib/builder/loadout.test.ts @@ -4,76 +4,78 @@ import { createDraft, parseLoadoutDraft } from './loadout.js'; const copyDraft = () => structuredClone(createDraft()); describe('parseLoadoutDraft', () => { - it('accepts a current draft and returns an independent clone', () => { - const draft = createDraft(); - const parsed = parseLoadoutDraft(draft); + it('accepts a current draft and returns an independent clone', () => { + const draft = createDraft(); + const parsed = parseLoadoutDraft(draft); - expect(parsed).toEqual(draft); - expect(parsed?.calculationMode).toBe('maximum'); - expect(parsed).not.toBe(draft); - parsed!.gear.Holster.level = 1; - expect(draft.gear.Holster.level).toBe(50); - }); + expect(parsed).toEqual(draft); + expect(parsed?.calculationMode).toBe('maximum'); + expect(parsed).not.toBe(draft); + parsed!.gear.Holster.level = 1; + expect(draft.gear.Holster.level).toBe(50); + }); - it('rejects the retired version 1 three-chip shape', () => { - const retired = copyDraft() as unknown as Record; - retired.version = 1; - retired.chips = (retired.chips as unknown[]).slice(0, 3); + it('rejects the retired version 1 three-chip shape', () => { + const retired = copyDraft() as unknown as Record; + retired.version = 1; + retired.chips = (retired.chips as unknown[]).slice(0, 3); - expect(parseLoadoutDraft(retired)).toBeNull(); - }); + expect(parseLoadoutDraft(retired)).toBeNull(); + }); - it('rejects a version 4 draft that does not contain exactly nine chips', () => { - const draft = copyDraft(); - draft.chips.pop(); + it('rejects a version 4 draft that does not contain exactly nine chips', () => { + const draft = copyDraft(); + draft.chips.pop(); - expect(parseLoadoutDraft(draft)).toBeNull(); - }); + expect(parseLoadoutDraft(draft)).toBeNull(); + }); - it('rejects a draft missing the unified combat profile', () => { - const draft = copyDraft() as unknown as Record; - delete draft.combatProfile; + it('rejects a draft missing the unified combat profile', () => { + const draft = copyDraft() as unknown as Record; + delete draft.combatProfile; - expect(parseLoadoutDraft(draft)).toBeNull(); - }); + expect(parseLoadoutDraft(draft)).toBeNull(); + }); - it('accepts a custom scenario and preserves its assumptions', () => { - const draft = copyDraft(); - draft.calculationMode = 'custom'; - draft.combatProfile.hitRate = 63; - draft.combatProfile.targetDistanceMeters = 28; - draft.conditionalEffects.relentless = { - mode: 'encounter', - inputs: { - alliesWithinRange: 3, - targetHasDebuff: true - } - }; + it('accepts a custom scenario and preserves its assumptions', () => { + const draft = copyDraft(); + draft.calculationMode = 'custom'; + draft.combatProfile.hitRate = 63; + draft.combatProfile.targetDistanceMeters = 28; + draft.conditionalEffects.relentless = { + mode: 'encounter', + inputs: { + alliesWithinRange: 3, + targetHasDebuff: true, + }, + }; - expect(parseLoadoutDraft(draft)).toEqual(draft); - }); + expect(parseLoadoutDraft(draft)).toEqual(draft); + }); - it('rejects drafts without the required calculation model', () => { - const missing = copyDraft() as unknown as Record; - delete missing.calculationMode; - expect(parseLoadoutDraft(missing)).toBeNull(); + it('rejects drafts without the required calculation model', () => { + const missing = copyDraft() as unknown as Record; + delete missing.calculationMode; + expect(parseLoadoutDraft(missing)).toBeNull(); - const invalid = copyDraft() as unknown as Record; - invalid.calculationMode = 'legacy'; - expect(parseLoadoutDraft(invalid)).toBeNull(); - }); + const invalid = copyDraft() as unknown as Record; + invalid.calculationMode = 'legacy'; + expect(parseLoadoutDraft(invalid)).toBeNull(); + }); - it('rejects a specialization that carries retired unassigned chips', () => { - const draft = copyDraft() as unknown as { specialization: Record }; - draft.specialization.unassignedChips = []; + it('rejects a specialization that carries retired unassigned chips', () => { + const draft = copyDraft() as unknown as { + specialization: Record; + }; + draft.specialization.unassignedChips = []; - expect(parseLoadoutDraft(draft)).toBeNull(); - }); + expect(parseLoadoutDraft(draft)).toBeNull(); + }); - it('rejects malformed nested equipment selections', () => { - const draft = copyDraft(); - draft.weapons[0].rolls[1] = { statId: 'weapon-damage', value: Number.NaN }; + it('rejects malformed nested equipment selections', () => { + const draft = copyDraft(); + draft.weapons[0].rolls[1] = { statId: 'weapon-damage', value: Number.NaN }; - expect(parseLoadoutDraft(draft)).toBeNull(); - }); + expect(parseLoadoutDraft(draft)).toBeNull(); + }); }); diff --git a/src/lib/builder/loadout.ts b/src/lib/builder/loadout.ts index e465386..5fa7ca0 100644 --- a/src/lib/builder/loadout.ts +++ b/src/lib/builder/loadout.ts @@ -1,279 +1,514 @@ export type Roll = { statId: string; value: number }; -export type GearSelection = { itemId: string; level: number; brandId: string; talentId: string; health: number; armor: number; firepower: number; toughness: number; engineering: number; rolls: [Roll, Roll]; augmentSlots: number[] }; -export type WeaponSelection = { itemId: string; level: number; tier: number; damage: number; firepower: number; toughness: number; engineering: number; talent1Id: string; talent2Id: string; rolls: [Roll, Roll] }; -export type ChipSelection = { setId: string; quality: 'Superior' | 'High-end'; primary: Roll; secondary: [Roll, Roll] }; -export type ProtocolSelection = { itemId: string; level: number; talentUpgrades: number }; +export type GearSelection = { + itemId: string; + level: number; + brandId: string; + talentId: string; + health: number; + armor: number; + firepower: number; + toughness: number; + engineering: number; + rolls: [Roll, Roll]; + augmentSlots: number[]; +}; +export type WeaponSelection = { + itemId: string; + level: number; + tier: number; + damage: number; + firepower: number; + toughness: number; + engineering: number; + talent1Id: string; + talent2Id: string; + rolls: [Roll, Roll]; +}; +export type ChipSelection = { + setId: string; + quality: 'Superior' | 'High-end'; + primary: Roll; + secondary: [Roll, Roll]; +}; +export type ProtocolSelection = { + itemId: string; + level: number; + talentUpgrades: number; +}; export type CombatProfile = { - encounterSeconds: number; - hitRate: number; - headshotRate: number; - shotsPerBurst: number; - burstPauseSeconds: number; - sameTargetSeconds: number; - distinctTargetsPerMinute: number; - killsPerMinute: number; - headshotKillRate: number; - damageTakenPerMinute: number; - swapsPerMinute: number; - targetDistanceMeters: number; - healthPercent: number; - extraHealthActive: boolean; + encounterSeconds: number; + hitRate: number; + headshotRate: number; + shotsPerBurst: number; + burstPauseSeconds: number; + sameTargetSeconds: number; + distinctTargetsPerMinute: number; + killsPerMinute: number; + headshotKillRate: number; + damageTakenPerMinute: number; + swapsPerMinute: number; + targetDistanceMeters: number; + healthPercent: number; + extraHealthActive: boolean; }; export type CalculationMode = 'maximum' | 'custom'; export type EffectMode = 'off' | 'peak' | 'encounter'; export type ConditionalEffectSelection = { - mode: EffectMode; - overrides?: Partial; - inputs?: Record; + mode: EffectMode; + overrides?: Partial; + inputs?: Record; }; export type SkillActivationSelection = { - mode: 'off' | 'on_cooldown' | 'custom_per_minute'; - usesPerMinute?: number; - targets: number; + mode: 'off' | 'on_cooldown' | 'custom_per_minute'; + usesPerMinute?: number; + targets: number; }; export type SpecializationSelection = { - focusId: string; - nodeRanks: Record; + focusId: string; + nodeRanks: Record; }; export type LoadoutDraft = { - version: 4; - buildName: string; - calculationMode: CalculationMode; - gear: Record; - weapons: [WeaponSelection, WeaponSelection]; - chips: [ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection]; - protocol: ProtocolSelection; - conditionalEffects: Record; - combatProfile: CombatProfile; - skillActivations: Record; - specialization: SpecializationSelection; + version: 4; + buildName: string; + calculationMode: CalculationMode; + gear: Record; + weapons: [WeaponSelection, WeaponSelection]; + chips: [ + ChipSelection, + ChipSelection, + ChipSelection, + ChipSelection, + ChipSelection, + ChipSelection, + ChipSelection, + ChipSelection, + ChipSelection, + ]; + protocol: ProtocolSelection; + conditionalEffects: Record; + combatProfile: CombatProfile; + skillActivations: Record; + specialization: SpecializationSelection; }; -export const gearSlots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'] as const; +export const gearSlots = [ + 'Holster', + 'Gloves', + 'Backpack', + 'Knee Pads', + 'Body Armor', + 'Mask', +] as const; export const blankRoll = (): Roll => ({ statId: '', value: 0 }); -export const blankGear = (): GearSelection => ({ itemId: '', level: 50, brandId: '', talentId: '', health: 0, armor: 0, firepower: 0, toughness: 0, engineering: 0, rolls: [blankRoll(), blankRoll()], augmentSlots: [] }); -export const blankWeapon = (): WeaponSelection => ({ itemId: '', level: 50, tier: 0, damage: 0, firepower: 0, toughness: 0, engineering: 0, talent1Id: '', talent2Id: '', rolls: [blankRoll(), blankRoll()] }); -export const blankChip = (): ChipSelection => ({ setId: '', quality: 'Superior', primary: blankRoll(), secondary: [blankRoll(), blankRoll()] }); +export const blankGear = (): GearSelection => ({ + itemId: '', + level: 50, + brandId: '', + talentId: '', + health: 0, + armor: 0, + firepower: 0, + toughness: 0, + engineering: 0, + rolls: [blankRoll(), blankRoll()], + augmentSlots: [], +}); +export const blankWeapon = (): WeaponSelection => ({ + itemId: '', + level: 50, + tier: 0, + damage: 0, + firepower: 0, + toughness: 0, + engineering: 0, + talent1Id: '', + talent2Id: '', + rolls: [blankRoll(), blankRoll()], +}); +export const blankChip = (): ChipSelection => ({ + setId: '', + quality: 'Superior', + primary: blankRoll(), + secondary: [blankRoll(), blankRoll()], +}); export const defaultCombatProfile = (): CombatProfile => ({ - encounterSeconds: 60, - hitRate: 75, - headshotRate: 20, - shotsPerBurst: 30, - burstPauseSeconds: 0, - sameTargetSeconds: 5, - distinctTargetsPerMinute: 12, - killsPerMinute: 4, - headshotKillRate: 20, - damageTakenPerMinute: 6, - swapsPerMinute: 2, - targetDistanceMeters: 15, - healthPercent: 100, - extraHealthActive: false + encounterSeconds: 60, + hitRate: 75, + headshotRate: 20, + shotsPerBurst: 30, + burstPauseSeconds: 0, + sameTargetSeconds: 5, + distinctTargetsPerMinute: 12, + killsPerMinute: 4, + headshotKillRate: 20, + damageTakenPerMinute: 6, + swapsPerMinute: 2, + targetDistanceMeters: 15, + healthPercent: 100, + extraHealthActive: false, }); export const createDraft = (): LoadoutDraft => ({ - version: 4, - buildName: 'Untitled loadout', - calculationMode: 'maximum', - gear: Object.fromEntries(gearSlots.map((slot) => [slot, blankGear()])), - weapons: [blankWeapon(), blankWeapon()], - chips: [blankChip(), blankChip(), blankChip(), blankChip(), blankChip(), blankChip(), blankChip(), blankChip(), blankChip()], - protocol: { itemId: '', level: 1, talentUpgrades: 0 }, - conditionalEffects: {}, - combatProfile: defaultCombatProfile(), - skillActivations: {}, - specialization: { - focusId: '', - nodeRanks: {} - } + version: 4, + buildName: 'Untitled loadout', + calculationMode: 'maximum', + gear: Object.fromEntries(gearSlots.map((slot) => [slot, blankGear()])), + weapons: [blankWeapon(), blankWeapon()], + chips: [ + blankChip(), + blankChip(), + blankChip(), + blankChip(), + blankChip(), + blankChip(), + blankChip(), + blankChip(), + blankChip(), + ], + protocol: { itemId: '', level: 1, talentUpgrades: 0 }, + conditionalEffects: {}, + combatProfile: defaultCombatProfile(), + skillActivations: {}, + specialization: { + focusId: '', + nodeRanks: {}, + }, }); const isRecord = (value: unknown): value is Record => - Boolean(value) && typeof value === 'object' && !Array.isArray(value); + Boolean(value) && typeof value === 'object' && !Array.isArray(value); const hasExactKeys = (value: Record, keys: string[]) => - Object.keys(value).length === keys.length && keys.every((key) => key in value); -const isFiniteNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value); + Object.keys(value).length === keys.length && + keys.every((key) => key in value); +const isFiniteNumber = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value); const isNumberRecord = (value: unknown) => - isRecord(value) && Object.values(value).every((entry) => typeof entry === 'number' && Number.isFinite(entry)); + isRecord(value) && + Object.values(value).every( + (entry) => typeof entry === 'number' && Number.isFinite(entry), + ); const isRoll = (value: unknown): value is Roll => - isRecord(value) - && hasExactKeys(value, ['statId', 'value']) - && typeof value.statId === 'string' - && isFiniteNumber(value.value); + isRecord(value) && + hasExactKeys(value, ['statId', 'value']) && + typeof value.statId === 'string' && + isFiniteNumber(value.value); const isRollPair = (value: unknown): value is [Roll, Roll] => - Array.isArray(value) && value.length === 2 && value.every(isRoll); + Array.isArray(value) && value.length === 2 && value.every(isRoll); const isGearSelection = (value: unknown): value is GearSelection => - isRecord(value) - && hasExactKeys(value, ['itemId', 'level', 'brandId', 'talentId', 'health', 'armor', 'firepower', 'toughness', 'engineering', 'rolls', 'augmentSlots']) - && ['itemId', 'brandId', 'talentId'].every((key) => typeof value[key] === 'string') - && ['level', 'health', 'armor', 'firepower', 'toughness', 'engineering'].every((key) => isFiniteNumber(value[key])) - && isRollPair(value.rolls) - && Array.isArray(value.augmentSlots) - && value.augmentSlots.every(isFiniteNumber); + isRecord(value) && + hasExactKeys(value, [ + 'itemId', + 'level', + 'brandId', + 'talentId', + 'health', + 'armor', + 'firepower', + 'toughness', + 'engineering', + 'rolls', + 'augmentSlots', + ]) && + ['itemId', 'brandId', 'talentId'].every( + (key) => typeof value[key] === 'string', + ) && + ['level', 'health', 'armor', 'firepower', 'toughness', 'engineering'].every( + (key) => isFiniteNumber(value[key]), + ) && + isRollPair(value.rolls) && + Array.isArray(value.augmentSlots) && + value.augmentSlots.every(isFiniteNumber); const isWeaponSelection = (value: unknown): value is WeaponSelection => - isRecord(value) - && hasExactKeys(value, ['itemId', 'level', 'tier', 'damage', 'firepower', 'toughness', 'engineering', 'talent1Id', 'talent2Id', 'rolls']) - && ['itemId', 'talent1Id', 'talent2Id'].every((key) => typeof value[key] === 'string') - && ['level', 'tier', 'damage', 'firepower', 'toughness', 'engineering'].every((key) => isFiniteNumber(value[key])) - && isRollPair(value.rolls); + isRecord(value) && + hasExactKeys(value, [ + 'itemId', + 'level', + 'tier', + 'damage', + 'firepower', + 'toughness', + 'engineering', + 'talent1Id', + 'talent2Id', + 'rolls', + ]) && + ['itemId', 'talent1Id', 'talent2Id'].every( + (key) => typeof value[key] === 'string', + ) && + ['level', 'tier', 'damage', 'firepower', 'toughness', 'engineering'].every( + (key) => isFiniteNumber(value[key]), + ) && + isRollPair(value.rolls); const isChipSelection = (value: unknown): value is ChipSelection => - isRecord(value) - && hasExactKeys(value, ['setId', 'quality', 'primary', 'secondary']) - && typeof value.setId === 'string' - && (value.quality === 'Superior' || value.quality === 'High-end') - && isRoll(value.primary) - && isRollPair(value.secondary); + isRecord(value) && + hasExactKeys(value, ['setId', 'quality', 'primary', 'secondary']) && + typeof value.setId === 'string' && + (value.quality === 'Superior' || value.quality === 'High-end') && + isRoll(value.primary) && + isRollPair(value.secondary); const isProtocolSelection = (value: unknown): value is ProtocolSelection => - isRecord(value) - && hasExactKeys(value, ['itemId', 'level', 'talentUpgrades']) - && typeof value.itemId === 'string' - && isFiniteNumber(value.level) - && isFiniteNumber(value.talentUpgrades); + isRecord(value) && + hasExactKeys(value, ['itemId', 'level', 'talentUpgrades']) && + typeof value.itemId === 'string' && + isFiniteNumber(value.level) && + isFiniteNumber(value.talentUpgrades); const isCombatProfile = (value: unknown): value is CombatProfile => - isRecord(value) - && hasExactKeys(value, [ - 'encounterSeconds', 'hitRate', 'headshotRate', 'shotsPerBurst', 'burstPauseSeconds', - 'sameTargetSeconds', 'distinctTargetsPerMinute', 'killsPerMinute', 'headshotKillRate', 'damageTakenPerMinute', - 'swapsPerMinute', 'targetDistanceMeters', 'healthPercent', 'extraHealthActive' - ]) - && [ - 'encounterSeconds', 'hitRate', 'headshotRate', 'shotsPerBurst', 'burstPauseSeconds', - 'sameTargetSeconds', 'distinctTargetsPerMinute', 'killsPerMinute', 'headshotKillRate', 'damageTakenPerMinute', - 'swapsPerMinute', 'targetDistanceMeters', 'healthPercent' - ].every((key) => isFiniteNumber(value[key])) - && typeof value.extraHealthActive === 'boolean'; -const isEffectStates = (value: unknown): value is Record => - isRecord(value) && Object.values(value).every((entry) => { - if (!isRecord(entry) - || !['off', 'peak', 'encounter'].includes(String(entry.mode))) return false; - const allowed = new Set(['mode', 'overrides', 'inputs']); - if (Object.keys(entry).some((key) => !allowed.has(key)) - || !['off', 'peak', 'encounter'].includes(String(entry.mode))) return false; - if (entry.inputs !== undefined && ( - !isRecord(entry.inputs) - || Object.values(entry.inputs).some((input) => - typeof input !== 'boolean' && !isFiniteNumber(input) - ) - )) return false; - return entry.overrides === undefined || ( - isRecord(entry.overrides) - && Object.entries(entry.overrides).every(([key, override]) => - key === 'extraHealthActive' - ? typeof override === 'boolean' - : key in defaultCombatProfile() && isFiniteNumber(override) - ) - ); - }); -const isSkillActivations = (value: unknown): value is Record => - isRecord(value) && Object.values(value).every((entry) => - isRecord(entry) - && ['off', 'on_cooldown', 'custom_per_minute'].includes(String(entry.mode)) - && isFiniteNumber(entry.targets) - && (entry.usesPerMinute === undefined || isFiniteNumber(entry.usesPerMinute)) - && Object.keys(entry).every((key) => ['mode', 'usesPerMinute', 'targets'].includes(key)) - ); + isRecord(value) && + hasExactKeys(value, [ + 'encounterSeconds', + 'hitRate', + 'headshotRate', + 'shotsPerBurst', + 'burstPauseSeconds', + 'sameTargetSeconds', + 'distinctTargetsPerMinute', + 'killsPerMinute', + 'headshotKillRate', + 'damageTakenPerMinute', + 'swapsPerMinute', + 'targetDistanceMeters', + 'healthPercent', + 'extraHealthActive', + ]) && + [ + 'encounterSeconds', + 'hitRate', + 'headshotRate', + 'shotsPerBurst', + 'burstPauseSeconds', + 'sameTargetSeconds', + 'distinctTargetsPerMinute', + 'killsPerMinute', + 'headshotKillRate', + 'damageTakenPerMinute', + 'swapsPerMinute', + 'targetDistanceMeters', + 'healthPercent', + ].every((key) => isFiniteNumber(value[key])) && + typeof value.extraHealthActive === 'boolean'; +const isEffectStates = ( + value: unknown, +): value is Record => + isRecord(value) && + Object.values(value).every((entry) => { + if ( + !isRecord(entry) || + !['off', 'peak', 'encounter'].includes(String(entry.mode)) + ) + return false; + const allowed = new Set(['mode', 'overrides', 'inputs']); + if ( + Object.keys(entry).some((key) => !allowed.has(key)) || + !['off', 'peak', 'encounter'].includes(String(entry.mode)) + ) + return false; + if ( + entry.inputs !== undefined && + (!isRecord(entry.inputs) || + Object.values(entry.inputs).some( + (input) => typeof input !== 'boolean' && !isFiniteNumber(input), + )) + ) + return false; + return ( + entry.overrides === undefined || + (isRecord(entry.overrides) && + Object.entries(entry.overrides).every(([key, override]) => + key === 'extraHealthActive' + ? typeof override === 'boolean' + : key in defaultCombatProfile() && isFiniteNumber(override), + )) + ); + }); +const isSkillActivations = ( + value: unknown, +): value is Record => + isRecord(value) && + Object.values(value).every( + (entry) => + isRecord(entry) && + ['off', 'on_cooldown', 'custom_per_minute'].includes( + String(entry.mode), + ) && + isFiniteNumber(entry.targets) && + (entry.usesPerMinute === undefined || + isFiniteNumber(entry.usesPerMinute)) && + Object.keys(entry).every((key) => + ['mode', 'usesPerMinute', 'targets'].includes(key), + ), + ); export function parseLoadoutDraft(value: unknown): LoadoutDraft | null { - if (!isRecord(value) - || !hasExactKeys(value, [ - 'version', 'buildName', 'calculationMode', 'gear', 'weapons', 'chips', 'protocol', - 'conditionalEffects', 'combatProfile', 'skillActivations', 'specialization' - ]) - || value.version !== 4 - || typeof value.buildName !== 'string' - || !['maximum', 'custom'].includes(String(value.calculationMode))) return null; - const gear = value.gear; - if (!isRecord(gear) || Object.keys(gear).length !== gearSlots.length - || !gearSlots.every((slot) => isGearSelection(gear[slot]))) return null; - if (!Array.isArray(value.weapons) || value.weapons.length !== 2 || !value.weapons.every(isWeaponSelection)) return null; - if (!Array.isArray(value.chips) || value.chips.length !== 9 || !value.chips.every(isChipSelection)) return null; - if (!isProtocolSelection(value.protocol) - || !isEffectStates(value.conditionalEffects) - || !isCombatProfile(value.combatProfile) - || !isSkillActivations(value.skillActivations)) return null; - if (!isRecord(value.specialization) - || !hasExactKeys(value.specialization, ['focusId', 'nodeRanks']) - || typeof value.specialization.focusId !== 'string' - || !isNumberRecord(value.specialization.nodeRanks)) return null; - return structuredClone(value) as LoadoutDraft; + if ( + !isRecord(value) || + !hasExactKeys(value, [ + 'version', + 'buildName', + 'calculationMode', + 'gear', + 'weapons', + 'chips', + 'protocol', + 'conditionalEffects', + 'combatProfile', + 'skillActivations', + 'specialization', + ]) || + value.version !== 4 || + typeof value.buildName !== 'string' || + !['maximum', 'custom'].includes(String(value.calculationMode)) + ) + return null; + const gear = value.gear; + if ( + !isRecord(gear) || + Object.keys(gear).length !== gearSlots.length || + !gearSlots.every((slot) => isGearSelection(gear[slot])) + ) + return null; + if ( + !Array.isArray(value.weapons) || + value.weapons.length !== 2 || + !value.weapons.every(isWeaponSelection) + ) + return null; + if ( + !Array.isArray(value.chips) || + value.chips.length !== 9 || + !value.chips.every(isChipSelection) + ) + return null; + if ( + !isProtocolSelection(value.protocol) || + !isEffectStates(value.conditionalEffects) || + !isCombatProfile(value.combatProfile) || + !isSkillActivations(value.skillActivations) + ) + return null; + if ( + !isRecord(value.specialization) || + !hasExactKeys(value.specialization, ['focusId', 'nodeRanks']) || + typeof value.specialization.focusId !== 'string' || + !isNumberRecord(value.specialization.nodeRanks) + ) + return null; + return structuredClone(value) as LoadoutDraft; } export const clamp = (value: unknown, min: unknown, max: unknown) => { - const low = Number(min), high = Number(max), numeric = Number(value); - if (!Number.isFinite(numeric)) return Number.isFinite(low) ? low : 0; - return Math.min(Number.isFinite(high) ? high : numeric, Math.max(Number.isFinite(low) ? low : numeric, numeric)); + const low = Number(min), + high = Number(max), + numeric = Number(value); + if (!Number.isFinite(numeric)) return Number.isFinite(low) ? low : 0; + return Math.min( + Number.isFinite(high) ? high : numeric, + Math.max(Number.isFinite(low) ? low : numeric, numeric), + ); }; -export const scaled = (min: unknown, max: unknown, progress: number, steps: number) => - Number(min) + (Number(max) - Number(min)) * Math.min(steps, Math.max(0, progress)) / steps; +export const scaled = ( + min: unknown, + max: unknown, + progress: number, + steps: number, +) => + Number(min) + + ((Number(max) - Number(min)) * Math.min(steps, Math.max(0, progress))) / + steps; export type LevelScaledRange = { - rawMin: number; - rawMax: number; - min: number; - max: number; - provisional: boolean; + rawMin: number; + rawMax: number; + min: number; + max: number; + provisional: boolean; }; const itemLevel = (value: unknown) => Math.round(clamp(value, 1, 50)); -export const gearMultiplier = (level: unknown) => 0.91 + 0.05 * itemLevel(level); -export const weaponMultiplier = (level: unknown) => 1.14 + 0.05 * itemLevel(level); +export const gearMultiplier = (level: unknown) => + 0.91 + 0.05 * itemLevel(level); +export const weaponMultiplier = (level: unknown) => + 1.14 + 0.05 * itemLevel(level); -export function gearBaseStatRange(minimum: unknown, maximum: unknown, targetLevel: unknown): LevelScaledRange { - const target = itemLevel(targetLevel); - const configuredMin = Number(minimum); - const configuredMax = Number(maximum); - const rawMin = (Number.isFinite(configuredMin) ? configuredMin : 0) * gearMultiplier(target); - const rawMax = (Number.isFinite(configuredMax) ? configuredMax : 0) * gearMultiplier(target); - return { - rawMin, - rawMax, - min: Math.floor(rawMin), - max: Math.floor(rawMax), - provisional: target < 10 - }; +export function gearBaseStatRange( + minimum: unknown, + maximum: unknown, + targetLevel: unknown, +): LevelScaledRange { + const target = itemLevel(targetLevel); + const configuredMin = Number(minimum); + const configuredMax = Number(maximum); + const rawMin = + (Number.isFinite(configuredMin) ? configuredMin : 0) * + gearMultiplier(target); + const rawMax = + (Number.isFinite(configuredMax) ? configuredMax : 0) * + gearMultiplier(target); + return { + rawMin, + rawMax, + min: Math.floor(rawMin), + max: Math.floor(rawMax), + provisional: target < 10, + }; } -export function weaponBaseRange(minimum: unknown, maximum: unknown, targetLevel: unknown): LevelScaledRange { - const target = itemLevel(targetLevel); - const configuredMin = Number(minimum); - const configuredMax = Number(maximum); - const rawMin = (Number.isFinite(configuredMin) ? configuredMin : 0) * weaponMultiplier(target); - const rawMax = (Number.isFinite(configuredMax) ? configuredMax : 0) * weaponMultiplier(target); - return { - rawMin, - rawMax, - min: Math.floor(rawMin), - max: Math.floor(rawMax), - provisional: target < 40 - }; +export function weaponBaseRange( + minimum: unknown, + maximum: unknown, + targetLevel: unknown, +): LevelScaledRange { + const target = itemLevel(targetLevel); + const configuredMin = Number(minimum); + const configuredMax = Number(maximum); + const rawMin = + (Number.isFinite(configuredMin) ? configuredMin : 0) * + weaponMultiplier(target); + const rawMax = + (Number.isFinite(configuredMax) ? configuredMax : 0) * + weaponMultiplier(target); + return { + rawMin, + rawMax, + min: Math.floor(rawMin), + max: Math.floor(rawMax), + provisional: target < 40, + }; } export function levelScaledRange( - minimum: unknown, - maximum: unknown, - targetLevel: unknown, - referenceLevel: unknown, - source: 'gear' | 'weapon' + minimum: unknown, + maximum: unknown, + targetLevel: unknown, + referenceLevel: unknown, + source: 'gear' | 'weapon', ): LevelScaledRange { - const target = itemLevel(targetLevel); - const reference = itemLevel(referenceLevel); - const multiplier = source === 'gear' ? gearMultiplier : weaponMultiplier; - const ratio = multiplier(target) / multiplier(reference); - const configuredMin = Number(minimum); - const configuredMax = Number(maximum); - const rawMin = (Number.isFinite(configuredMin) ? configuredMin : 0) * ratio; - const rawMax = (Number.isFinite(configuredMax) ? configuredMax : 0) * ratio; - return { - rawMin, - rawMax, - min: Math.floor(rawMin), - max: Math.floor(rawMax), - provisional: source === 'gear' ? target < 10 || reference < 10 : target < 40 || reference < 40 - }; + const target = itemLevel(targetLevel); + const reference = itemLevel(referenceLevel); + const multiplier = source === 'gear' ? gearMultiplier : weaponMultiplier; + const ratio = multiplier(target) / multiplier(reference); + const configuredMin = Number(minimum); + const configuredMax = Number(maximum); + const rawMin = (Number.isFinite(configuredMin) ? configuredMin : 0) * ratio; + const rawMax = (Number.isFinite(configuredMax) ? configuredMax : 0) * ratio; + return { + rawMin, + rawMax, + min: Math.floor(rawMin), + max: Math.floor(rawMax), + provisional: + source === 'gear' + ? target < 10 || reference < 10 + : target < 40 || reference < 40, + }; } -export function remapRangeValue(value: unknown, from: LevelScaledRange, to: LevelScaledRange) { - const span = from.max - from.min; - const position = span > 0 ? (clamp(value, from.min, from.max) - from.min) / span : 0; - return Math.round(to.min + (to.max - to.min) * position); +export function remapRangeValue( + value: unknown, + from: LevelScaledRange, + to: LevelScaledRange, +) { + const span = from.max - from.min; + const position = + span > 0 ? (clamp(value, from.min, from.max) - from.min) / span : 0; + return Math.round(to.min + (to.max - to.min) * position); } diff --git a/src/lib/calculations/calculation-script.test.ts b/src/lib/calculations/calculation-script.test.ts index 8aeb3ee..f06fcc7 100644 --- a/src/lib/calculations/calculation-script.test.ts +++ b/src/lib/calculations/calculation-script.test.ts @@ -1,39 +1,66 @@ import { describe, expect, it } from 'vitest'; import { - formatItemDamageRangeScript, - formatWeaponCalculationScript + formatItemDamageRangeScript, + formatWeaponCalculationScript, } from './calculation-script'; describe('formatWeaponCalculationScript', () => { - it('starts from the selected equipped damage and explains ordered typed stages', () => { - const lines = formatWeaponCalculationScript({ - revision: { version: 4, name: 'Resurgence model' }, - stages: [ - { stage_key: 'base', label: 'Base damage', operator: 'base_damage', display_order: 1 }, - { stage_key: 'flat', label: 'Flat damage', operator: 'flat_add', display_order: 2 }, - { stage_key: 'cycle', label: 'Firing cycle', operator: 'firing_cycle', display_order: 3 } - ], - profiles: [{ - stat_attribute_id: 'weapon-flat', - stage_key: 'flat', - weight: 1.5, - soft_cap: null, - hard_cap: 100, - curve_type: 'none', - curve_parameter: null, - weapon_category: 'SMG' - }], - attributes: [{ id: 'weapon-flat', name: 'Flat Weapon Damage', unit: null }] - }); - expect(lines).toContain('TAKE equipped_weapon.damage AS base_damage'); - expect(lines).toContain('ADD flat_weapon_damage × 1.5 HARD_CAP 100 FOR SMG TO damage'); - expect(lines.at(-1)).toBe('RETURN expected_damage_per_second'); - }); + it('starts from the selected equipped damage and explains ordered typed stages', () => { + const lines = formatWeaponCalculationScript({ + revision: { version: 4, name: 'Resurgence model' }, + stages: [ + { + stage_key: 'base', + label: 'Base damage', + operator: 'base_damage', + display_order: 1, + }, + { + stage_key: 'flat', + label: 'Flat damage', + operator: 'flat_add', + display_order: 2, + }, + { + stage_key: 'cycle', + label: 'Firing cycle', + operator: 'firing_cycle', + display_order: 3, + }, + ], + profiles: [ + { + stat_attribute_id: 'weapon-flat', + stage_key: 'flat', + weight: 1.5, + soft_cap: null, + hard_cap: 100, + curve_type: 'none', + curve_parameter: null, + weapon_category: 'SMG', + }, + ], + attributes: [ + { id: 'weapon-flat', name: 'Flat Weapon Damage', unit: null }, + ], + }); + expect(lines).toContain('TAKE equipped_weapon.damage AS base_damage'); + expect(lines).toContain( + 'ADD flat_weapon_damage × 1.5 HARD_CAP 100 FOR SMG TO damage', + ); + expect(lines.at(-1)).toBe('RETURN expected_damage_per_second'); + }); - it('keeps level scaling in item configuration instead of applying it twice', () => { - const lines = formatItemDamageRangeScript(); - expect(lines).toContain('SCALE configured_damage_range TO equipped_weapon.level'); - expect(lines).toContain('PASS equipped_weapon.damage TO combat_calculation'); - expect(lines).not.toContain('SCALE equipped_weapon.damage TO equipped_weapon.level'); - }); + it('keeps level scaling in item configuration instead of applying it twice', () => { + const lines = formatItemDamageRangeScript(); + expect(lines).toContain( + 'SCALE configured_damage_range TO equipped_weapon.level', + ); + expect(lines).toContain( + 'PASS equipped_weapon.damage TO combat_calculation', + ); + expect(lines).not.toContain( + 'SCALE equipped_weapon.damage TO equipped_weapon.level', + ); + }); }); diff --git a/src/lib/calculations/calculation-script.ts b/src/lib/calculations/calculation-script.ts index ff52e18..e9eaf10 100644 --- a/src/lib/calculations/calculation-script.ts +++ b/src/lib/calculations/calculation-script.ts @@ -1,131 +1,147 @@ export type CalculationScriptStage = { - stage_key: string; - label: string; - operator: string; - display_order: number; + stage_key: string; + label: string; + operator: string; + display_order: number; }; export type CalculationScriptProfile = { - stat_attribute_id: string; - stage_key: string; - weight?: number | null; - soft_cap?: number | null; - hard_cap?: number | null; - curve_type?: string | null; - curve_parameter?: number | null; - weapon_category?: string | null; + stat_attribute_id: string; + stage_key: string; + weight?: number | null; + soft_cap?: number | null; + hard_cap?: number | null; + curve_type?: string | null; + curve_parameter?: number | null; + weapon_category?: string | null; }; export type CalculationScriptAttribute = { - id: string; - name: string; - unit?: string | null; + id: string; + name: string; + unit?: string | null; }; export type CalculationScriptRevision = { - version?: number | null; - name?: string | null; + version?: number | null; + name?: string | null; }; const number = (value: unknown) => - Number.isInteger(Number(value)) ? String(Number(value)) : String(Number(Number(value).toFixed(3))); + Number.isInteger(Number(value)) + ? String(Number(value)) + : String(Number(Number(value).toFixed(3))); -const identifier = (value: unknown) => String(value ?? '') - .normalize('NFKC') - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, '_') - .replace(/^_+|_+$/g, '') || 'unnamed'; +const identifier = (value: unknown) => + String(value ?? '') + .normalize('NFKC') + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') || 'unnamed'; function profileExpression( - profile: CalculationScriptProfile, - attributes: Map + profile: CalculationScriptProfile, + attributes: Map, ) { - const attribute = attributes.get(profile.stat_attribute_id); - const unit = attribute?.unit ? ` ${attribute.unit}` : ''; - const weight = Number(profile.weight ?? 1); - let expression = `${identifier(attribute?.name ?? profile.stat_attribute_id)}${unit}`; - if (weight !== 1) expression += ` × ${number(weight)}`; - if (profile.soft_cap !== null && profile.soft_cap !== undefined) { - const curve = profile.curve_type && profile.curve_type !== 'none' - ? ` USING ${String(profile.curve_type).toUpperCase()}(${number(profile.curve_parameter ?? 1)})` - : ''; - expression += ` SOFT_CAP ${number(profile.soft_cap)}${curve}`; - } - if (profile.hard_cap !== null && profile.hard_cap !== undefined) { - expression += ` HARD_CAP ${number(profile.hard_cap)}`; - } - if (profile.weapon_category && profile.weapon_category !== '*') { - expression += ` FOR ${String(profile.weapon_category).toUpperCase()}`; - } - return expression; + const attribute = attributes.get(profile.stat_attribute_id); + const unit = attribute?.unit ? ` ${attribute.unit}` : ''; + const weight = Number(profile.weight ?? 1); + let expression = `${identifier(attribute?.name ?? profile.stat_attribute_id)}${unit}`; + if (weight !== 1) expression += ` × ${number(weight)}`; + if (profile.soft_cap !== null && profile.soft_cap !== undefined) { + const curve = + profile.curve_type && profile.curve_type !== 'none' + ? ` USING ${String(profile.curve_type).toUpperCase()}(${number(profile.curve_parameter ?? 1)})` + : ''; + expression += ` SOFT_CAP ${number(profile.soft_cap)}${curve}`; + } + if (profile.hard_cap !== null && profile.hard_cap !== undefined) { + expression += ` HARD_CAP ${number(profile.hard_cap)}`; + } + if (profile.weapon_category && profile.weapon_category !== '*') { + expression += ` FOR ${String(profile.weapon_category).toUpperCase()}`; + } + return expression; } -const operatorStatement = (operator: string, label: string, sources: string[]) => { - const source = sources.length ? sources.join(', ') : 'no_bound_attributes'; - switch (operator) { - case 'base_damage': - return ['TAKE equipped_weapon.damage AS base_damage']; - case 'flat_add': - return [`ADD ${source} TO damage`]; - case 'additive_percent': - return [`APPLY SUM(${source}) AS additive_percent TO damage`]; - case 'multiplicative_percent': - return [`APPLY EACH(${source}) AS multiplicative_percent TO damage`]; - case 'expected_critical': - return [ - `WEIGHT critical_damage BY critical_chance`, - ...(sources.length ? [`SOURCE ${source}`] : []) - ]; - case 'expected_headshot': - return [ - `WEIGHT headshot_damage BY headshot_chance`, - ...(sources.length ? [`SOURCE ${source}`] : []) - ]; - case 'rate_of_fire': - return [`ADJUST equipped_weapon.rpm USING ${source}`]; - case 'magazine_size': - return [`ADJUST equipped_weapon.magazine USING ${source}`]; - case 'reload_speed': - return [`ADJUST equipped_weapon.reload_seconds USING ${source}`]; - case 'firing_cycle': - return [ - 'CALCULATE firing_cycle FROM rpm, magazine, reload_seconds', - 'RETURN expected_damage_per_second' - ]; - default: - return [`APPLY ${identifier(label)} USING ${source}`]; - } +const operatorStatement = ( + operator: string, + label: string, + sources: string[], +) => { + const source = sources.length ? sources.join(', ') : 'no_bound_attributes'; + switch (operator) { + case 'base_damage': + return ['TAKE equipped_weapon.damage AS base_damage']; + case 'flat_add': + return [`ADD ${source} TO damage`]; + case 'additive_percent': + return [`APPLY SUM(${source}) AS additive_percent TO damage`]; + case 'multiplicative_percent': + return [`APPLY EACH(${source}) AS multiplicative_percent TO damage`]; + case 'expected_critical': + return [ + `WEIGHT critical_damage BY critical_chance`, + ...(sources.length ? [`SOURCE ${source}`] : []), + ]; + case 'expected_headshot': + return [ + `WEIGHT headshot_damage BY headshot_chance`, + ...(sources.length ? [`SOURCE ${source}`] : []), + ]; + case 'rate_of_fire': + return [`ADJUST equipped_weapon.rpm USING ${source}`]; + case 'magazine_size': + return [`ADJUST equipped_weapon.magazine USING ${source}`]; + case 'reload_speed': + return [`ADJUST equipped_weapon.reload_seconds USING ${source}`]; + case 'firing_cycle': + return [ + 'CALCULATE firing_cycle FROM rpm, magazine, reload_seconds', + 'RETURN expected_damage_per_second', + ]; + default: + return [`APPLY ${identifier(label)} USING ${source}`]; + } }; export function formatWeaponCalculationScript(input: { - revision?: CalculationScriptRevision | null; - stages: CalculationScriptStage[]; - profiles: CalculationScriptProfile[]; - attributes: CalculationScriptAttribute[]; + revision?: CalculationScriptRevision | null; + stages: CalculationScriptStage[]; + profiles: CalculationScriptProfile[]; + attributes: CalculationScriptAttribute[]; }) { - const attributes = new Map(input.attributes.map((attribute) => [String(attribute.id), attribute])); - const lines = [ - `CALCULATE expected_weapon_dps${input.revision?.version ? ` WITH rules_r${input.revision.version}` : ''}` - ]; - for (const stage of [...input.stages].sort( - (left, right) => Number(left.display_order) - Number(right.display_order) - )) { - const sources = input.profiles - .filter((profile) => profile.stage_key === stage.stage_key) - .map((profile) => profileExpression(profile, attributes)); - lines.push('', `STAGE ${String(stage.label).toUpperCase()}`); - lines.push(...operatorStatement(String(stage.operator), String(stage.label), sources)); - } - return lines; + const attributes = new Map( + input.attributes.map((attribute) => [String(attribute.id), attribute]), + ); + const lines = [ + `CALCULATE expected_weapon_dps${input.revision?.version ? ` WITH rules_r${input.revision.version}` : ''}`, + ]; + for (const stage of [...input.stages].sort( + (left, right) => Number(left.display_order) - Number(right.display_order), + )) { + const sources = input.profiles + .filter((profile) => profile.stage_key === stage.stage_key) + .map((profile) => profileExpression(profile, attributes)); + lines.push('', `STAGE ${String(stage.label).toUpperCase()}`); + lines.push( + ...operatorStatement( + String(stage.operator), + String(stage.label), + sources, + ), + ); + } + return lines; } export function formatItemDamageRangeScript() { - return [ - 'CONFIGURE equipped_weapon', - 'READ configured_damage_range AT level_1', - 'SCALE configured_damage_range TO equipped_weapon.level', - 'SELECT equipped_weapon.damage WITHIN scaled_damage_range', - 'PASS equipped_weapon.damage TO combat_calculation' - ]; + return [ + 'CONFIGURE equipped_weapon', + 'READ configured_damage_range AT level_1', + 'SCALE configured_damage_range TO equipped_weapon.level', + 'SELECT equipped_weapon.damage WITHIN scaled_damage_range', + 'PASS equipped_weapon.damage TO combat_calculation', + ]; } diff --git a/src/lib/calculations/combat.test.ts b/src/lib/calculations/combat.test.ts index c9430ae..4713099 100644 --- a/src/lib/calculations/combat.test.ts +++ b/src/lib/calculations/combat.test.ts @@ -1,141 +1,236 @@ import { describe, expect, it } from 'vitest'; import { - calculateCombat, - expectedWeaponShotDamage, - type CombatCalculationInput + calculateCombat, + expectedWeaponShotDamage, + type CombatCalculationInput, } from './combat.js'; -const base = (overrides: Partial = {}): CombatCalculationInput => ({ - weapon: { baseDamage: 100, rpm: 600, magazine: 10, reloadSeconds: 2, headshotMultiplier: 2 }, - attributes: { weapon: 20, critChance: 25, critDamage: 50, headshotChance: 0, final: 10 }, - bindings: [ - { attributeId: 'weapon', stage: 'weapon_damage', operator: 'add' }, - { attributeId: 'critChance', stage: 'critical_chance', operator: 'add' }, - { attributeId: 'critDamage', stage: 'critical_damage', operator: 'add' }, - { attributeId: 'headshotChance', stage: 'headshot_chance', operator: 'add' }, - { attributeId: 'final', stage: 'final_damage', operator: 'add' } - ], - assumptions: { mode: 'off', hitRate: 100, headshotRate: 0, firingActivity: 100, encounterSeconds: 20 }, - ...overrides +const base = ( + overrides: Partial = {}, +): CombatCalculationInput => ({ + weapon: { + baseDamage: 100, + rpm: 600, + magazine: 10, + reloadSeconds: 2, + headshotMultiplier: 2, + }, + attributes: { + weapon: 20, + critChance: 25, + critDamage: 50, + headshotChance: 0, + final: 10, + }, + bindings: [ + { attributeId: 'weapon', stage: 'weapon_damage', operator: 'add' }, + { attributeId: 'critChance', stage: 'critical_chance', operator: 'add' }, + { attributeId: 'critDamage', stage: 'critical_damage', operator: 'add' }, + { + attributeId: 'headshotChance', + stage: 'headshot_chance', + operator: 'add', + }, + { attributeId: 'final', stage: 'final_damage', operator: 'add' }, + ], + assumptions: { + mode: 'off', + hitRate: 100, + headshotRate: 0, + firingActivity: 100, + encounterSeconds: 20, + }, + ...overrides, }); describe('calculateCombat', () => { - it('calculates four hit values and reload-aware DPS', () => { - const result = calculateCombat(base()); - expect(result.status).toBe('OK'); - expect(result.hitValues).toEqual({ normal: 132, critical: 198, headshot: 264, criticalHeadshot: 396 }); - expect(result.burstDps).toBe(1485); - expect(result.sustainedDps).toBeCloseTo(495); - }); + it('calculates four hit values and reload-aware DPS', () => { + const result = calculateCombat(base()); + expect(result.status).toBe('OK'); + expect(result.hitValues).toEqual({ + normal: 132, + critical: 198, + headshot: 264, + criticalHeadshot: 396, + }); + expect(result.burstDps).toBe(1485); + expect(result.sustainedDps).toBeCloseTo(495); + }); - it('calculates expected body and headshot damage for timeline events', () => { - const input = base({ - attributes: { critChance: 50, critDamage: 100 }, - bindings: [ - { attributeId: 'critChance', stage: 'critical_chance', operator: 'add' }, - { attributeId: 'critDamage', stage: 'critical_damage', operator: 'add' } - ] - }); - expect(expectedWeaponShotDamage(input.weapon, input.attributes, input.bindings)).toBe(150); - expect(expectedWeaponShotDamage(input.weapon, input.attributes, input.bindings, true)).toBe(300); - }); + it('calculates expected body and headshot damage for timeline events', () => { + const input = base({ + attributes: { critChance: 50, critDamage: 100 }, + bindings: [ + { + attributeId: 'critChance', + stage: 'critical_chance', + operator: 'add', + }, + { + attributeId: 'critDamage', + stage: 'critical_damage', + operator: 'add', + }, + ], + }); + expect( + expectedWeaponShotDamage(input.weapon, input.attributes, input.bindings), + ).toBe(150); + expect( + expectedWeaponShotDamage( + input.weapon, + input.attributes, + input.bindings, + true, + ), + ).toBe(300); + }); - it('applies weights, hard caps, and a configured curve safely', () => { - const result = calculateCombat(base({ - attributes: { weapon: 100 }, - bindings: [{ - attributeId: 'weapon', - stage: 'weapon_damage', - operator: 'add', - weight: 2, - softCap: 50, - hardCap: 80, - curveType: 'power', - curveParameter: 1 - }] - })); - expect(result.hitValues?.normal).toBe(180); - }); + it('applies weights, hard caps, and a configured curve safely', () => { + const result = calculateCombat( + base({ + attributes: { weapon: 100 }, + bindings: [ + { + attributeId: 'weapon', + stage: 'weapon_damage', + operator: 'add', + weight: 2, + softCap: 50, + hardCap: 80, + curveType: 'power', + curveParameter: 1, + }, + ], + }), + ); + expect(result.hitValues?.normal).toBe(180); + }); - it('applies firing-cycle attributes to burst and sustained DPS', () => { - const result = calculateCombat(base({ - attributes: { rate: 20, magazine: 100, reload: 100 }, - bindings: [ - { attributeId: 'rate', stage: 'rate_of_fire', operator: 'add' }, - { attributeId: 'magazine', stage: 'magazine_size', operator: 'add' }, - { attributeId: 'reload', stage: 'reload_speed', operator: 'add' } - ] - })); - expect(result.status).toBe('OK'); - expect(result.burstDps).toBe(1200); - expect(result.sustainedDps).toBeCloseTo(750); - }); + it('applies firing-cycle attributes to burst and sustained DPS', () => { + const result = calculateCombat( + base({ + attributes: { rate: 20, magazine: 100, reload: 100 }, + bindings: [ + { attributeId: 'rate', stage: 'rate_of_fire', operator: 'add' }, + { attributeId: 'magazine', stage: 'magazine_size', operator: 'add' }, + { attributeId: 'reload', stage: 'reload_speed', operator: 'add' }, + ], + }), + ); + expect(result.status).toBe('OK'); + expect(result.burstDps).toBe(1200); + expect(result.sustainedDps).toBeCloseTo(750); + }); - it('keeps effects off, applies peak stacks, and honors per-effect overrides', () => { - const stackingEffects = [{ - id: 'relentless', - trigger: { hitsRequired: 9, windowSeconds: 3 }, - lifecycle: { maxStacks: 10, durationSeconds: 10, stacksPerTrigger: 1 }, - attributes: { weapon: 3, critDamage: 1 } - }]; - const off = calculateCombat(base({ stackingEffects })); - const peak = calculateCombat(base({ - stackingEffects, - assumptions: { mode: 'off', perEffect: { relentless: { mode: 'peak' } } } - })); - expect(off.hitValues?.normal).toBe(132); - expect(peak.hitValues?.normal).toBeCloseTo(165); - expect(peak.stackSummaries[0].stacksAtEnd).toBe(10); - }); + it('keeps effects off, applies peak stacks, and honors per-effect overrides', () => { + const stackingEffects = [ + { + id: 'relentless', + trigger: { hitsRequired: 9, windowSeconds: 3 }, + lifecycle: { maxStacks: 10, durationSeconds: 10, stacksPerTrigger: 1 }, + attributes: { weapon: 3, critDamage: 1 }, + }, + ]; + const off = calculateCombat(base({ stackingEffects })); + const peak = calculateCombat( + base({ + stackingEffects, + assumptions: { + mode: 'off', + perEffect: { relentless: { mode: 'peak' } }, + }, + }), + ); + expect(off.hitValues?.normal).toBe(132); + expect(peak.hitValues?.normal).toBeCloseTo(165); + expect(peak.stackSummaries[0].stacksAtEnd).toBe(10); + }); - it('models encounter stack ramp and refresh with the weapon firing cycle', () => { - const result = calculateCombat(base({ - stackingEffects: [{ - id: 'relentless', - trigger: { hitsRequired: 9, windowSeconds: 3 }, - lifecycle: { maxStacks: 10, durationSeconds: 10, stacksPerTrigger: 1 }, - attributes: { weapon: 3 } - }], - assumptions: { mode: 'encounter', hitRate: 100, firingActivity: 100, encounterSeconds: 30 } - })); - expect(result.encounterDps).not.toBeNull(); - expect(result.encounterDps!).toBeGreaterThan(495); - expect(result.stackSummaries[0].maxStacksReached).toBe(10); - expect(result.stackSummaries[0].maintained).toBe(true); - }); + it('models encounter stack ramp and refresh with the weapon firing cycle', () => { + const result = calculateCombat( + base({ + stackingEffects: [ + { + id: 'relentless', + trigger: { hitsRequired: 9, windowSeconds: 3 }, + lifecycle: { + maxStacks: 10, + durationSeconds: 10, + stacksPerTrigger: 1, + }, + attributes: { weapon: 3 }, + }, + ], + assumptions: { + mode: 'encounter', + hitRate: 100, + firingActivity: 100, + encounterSeconds: 30, + }, + }), + ); + expect(result.encounterDps).not.toBeNull(); + expect(result.encounterDps!).toBeGreaterThan(495); + expect(result.stackSummaries[0].maxStacksReached).toBe(10); + expect(result.stackSummaries[0].maintained).toBe(true); + }); - it('does not trigger when expected hits cannot meet the trigger window', () => { - const result = calculateCombat(base({ - stackingEffects: [{ - id: 'slow', - trigger: { hitsRequired: 9, windowSeconds: 0.5 }, - lifecycle: { maxStacks: 10, durationSeconds: 10 }, - attributes: { weapon: 3 } - }], - assumptions: { mode: 'encounter', hitRate: 10, firingActivity: 50, encounterSeconds: 20 } - })); - expect(result.stackSummaries[0]).toMatchObject({ maxStacksReached: 0, averageStacks: 0, maintained: false }); - }); + it('does not trigger when expected hits cannot meet the trigger window', () => { + const result = calculateCombat( + base({ + stackingEffects: [ + { + id: 'slow', + trigger: { hitsRequired: 9, windowSeconds: 0.5 }, + lifecycle: { maxStacks: 10, durationSeconds: 10 }, + attributes: { weapon: 3 }, + }, + ], + assumptions: { + mode: 'encounter', + hitRate: 10, + firingActivity: 50, + encounterSeconds: 20, + }, + }), + ); + expect(result.stackSummaries[0]).toMatchObject({ + maxStacksReached: 0, + averageStacks: 0, + maintained: false, + }); + }); - it('returns explicit requirements instead of partial results', () => { - const result = calculateCombat(base({ - weapon: { baseDamage: 100, rpm: 0, magazine: 0, reloadSeconds: -1 }, - assumptions: { mode: 'encounter' }, - stackingEffects: [{ - id: 'need-profile', - trigger: { hitsRequired: 1, windowSeconds: 1 }, - lifecycle: { maxStacks: 1, durationSeconds: 1 }, - attributes: {} - }] - })); - expect(result).toMatchObject({ status: 'INSUFFICIENT_DATA', hitValues: null, burstDps: null }); - expect(result.missing).toEqual(expect.arrayContaining([ - 'weapon.rpm', - 'weapon.magazine', - 'weapon.reloadSeconds', - 'assumptions.hitRate', - 'assumptions.firingActivity', - 'assumptions.encounterSeconds' - ])); - }); + it('returns explicit requirements instead of partial results', () => { + const result = calculateCombat( + base({ + weapon: { baseDamage: 100, rpm: 0, magazine: 0, reloadSeconds: -1 }, + assumptions: { mode: 'encounter' }, + stackingEffects: [ + { + id: 'need-profile', + trigger: { hitsRequired: 1, windowSeconds: 1 }, + lifecycle: { maxStacks: 1, durationSeconds: 1 }, + attributes: {}, + }, + ], + }), + ); + expect(result).toMatchObject({ + status: 'INSUFFICIENT_DATA', + hitValues: null, + burstDps: null, + }); + expect(result.missing).toEqual( + expect.arrayContaining([ + 'weapon.rpm', + 'weapon.magazine', + 'weapon.reloadSeconds', + 'assumptions.hitRate', + 'assumptions.firingActivity', + 'assumptions.encounterSeconds', + ]), + ); + }); }); diff --git a/src/lib/calculations/combat.ts b/src/lib/calculations/combat.ts index 645df2d..94e5c67 100644 --- a/src/lib/calculations/combat.ts +++ b/src/lib/calculations/combat.ts @@ -1,465 +1,600 @@ export type CalculationStage = - | 'flat_damage' - | 'weapon_damage' - | 'critical_chance' - | 'critical_damage' - | 'headshot_chance' - | 'headshot_damage' - | 'rate_of_fire' - | 'magazine_size' - | 'reload_speed' - | 'final_damage'; + | 'flat_damage' + | 'weapon_damage' + | 'critical_chance' + | 'critical_damage' + | 'headshot_chance' + | 'headshot_damage' + | 'rate_of_fire' + | 'magazine_size' + | 'reload_speed' + | 'final_damage'; export type CalculationOperator = 'add' | 'multiply'; export type CurveType = 'none' | 'power' | 'smoothstep'; export type AttributeBinding = { - attributeId: string; - stage: CalculationStage; - operator: CalculationOperator; - weight?: number; - softCap?: number | null; - hardCap?: number | null; - curveType?: CurveType; - curveParameter?: number | null; + attributeId: string; + stage: CalculationStage; + operator: CalculationOperator; + weight?: number; + softCap?: number | null; + hardCap?: number | null; + curveType?: CurveType; + curveParameter?: number | null; }; export type StatValues = Record; export type WeaponCombatInput = { - baseDamage?: number; - rpm?: number; - magazine?: number; - reloadSeconds?: number; - headshotMultiplier?: number; + baseDamage?: number; + rpm?: number; + magazine?: number; + reloadSeconds?: number; + headshotMultiplier?: number; }; export type WeaponFiringCycle = { - rpm: number; - magazine: number; - reloadSeconds: number; + rpm: number; + magazine: number; + reloadSeconds: number; }; export type StackingMode = 'off' | 'peak' | 'encounter'; export type TriggerRule = { hitsRequired: number; windowSeconds: number }; -export type StackLifecycle = { maxStacks: number; durationSeconds: number; stacksPerTrigger?: number }; +export type StackLifecycle = { + maxStacks: number; + durationSeconds: number; + stacksPerTrigger?: number; +}; export type StackingEffect = { - id: string; - trigger: TriggerRule; - lifecycle: StackLifecycle; - attributes: StatValues; + id: string; + trigger: TriggerRule; + lifecycle: StackLifecycle; + attributes: StatValues; }; export type CombatAssumptions = { - mode: StackingMode; - hitRate?: number; - headshotRate?: number; - firingActivity?: number; - encounterSeconds?: number; - perEffect?: Record>>; + mode: StackingMode; + hitRate?: number; + headshotRate?: number; + firingActivity?: number; + encounterSeconds?: number; + perEffect?: Record>>; }; export type MissingRequirement = - | 'weapon.baseDamage' - | 'weapon.rpm' - | 'weapon.magazine' - | 'weapon.reloadSeconds' - | 'assumptions.hitRate' - | 'assumptions.firingActivity' - | 'assumptions.encounterSeconds' - | `binding:${string}`; + | 'weapon.baseDamage' + | 'weapon.rpm' + | 'weapon.magazine' + | 'weapon.reloadSeconds' + | 'assumptions.hitRate' + | 'assumptions.firingActivity' + | 'assumptions.encounterSeconds' + | `binding:${string}`; export type DamageValues = { - normal: number; - critical: number; - headshot: number; - criticalHeadshot: number; + normal: number; + critical: number; + headshot: number; + criticalHeadshot: number; }; export type StackSummary = { - effectId: string; - mode: StackingMode; - stacksAtEnd: number; - maxStacksReached: number; - buildUpSeconds: number | null; - averageStacks: number; - maintained: boolean; + effectId: string; + mode: StackingMode; + stacksAtEnd: number; + maxStacksReached: number; + buildUpSeconds: number | null; + averageStacks: number; + maintained: boolean; }; export type CombatCalculation = { - status: 'OK' | 'INSUFFICIENT_DATA'; - missing: MissingRequirement[]; - hitValues: DamageValues | null; - burstDps: number | null; - sustainedDps: number | null; - encounterDps: number | null; - stackSummaries: StackSummary[]; + status: 'OK' | 'INSUFFICIENT_DATA'; + missing: MissingRequirement[]; + hitValues: DamageValues | null; + burstDps: number | null; + sustainedDps: number | null; + encounterDps: number | null; + stackSummaries: StackSummary[]; }; export type CombatCalculationInput = { - weapon: WeaponCombatInput; - attributes: StatValues; - bindings: AttributeBinding[]; - stageOrder?: CalculationStage[]; - stackingEffects?: StackingEffect[]; - assumptions: CombatAssumptions; + weapon: WeaponCombatInput; + attributes: StatValues; + bindings: AttributeBinding[]; + stageOrder?: CalculationStage[]; + stackingEffects?: StackingEffect[]; + assumptions: CombatAssumptions; }; type ResolvedAssumptions = { - mode: StackingMode; - hitRate: number; - headshotRate: number; - firingActivity: number; - encounterSeconds: number; + mode: StackingMode; + hitRate: number; + headshotRate: number; + firingActivity: number; + encounterSeconds: number; }; type StackState = { - stacks: number; - expiresAt: number; - maxStacksReached: number; - buildUpSeconds: number | null; + stacks: number; + expiresAt: number; + maxStacksReached: number; + buildUpSeconds: number | null; }; type TimedStackEffect = { - effect: StackingEffect; - assumptions: ResolvedAssumptions; - events: number[]; + effect: StackingEffect; + assumptions: ResolvedAssumptions; + events: number[]; }; const EPSILON = 0.000_001; -const finitePositive = (value: unknown) => Number.isFinite(Number(value)) && Number(value) > 0; -const finiteNonNegative = (value: unknown) => Number.isFinite(Number(value)) && Number(value) >= 0; -const clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value)); -const percent = (value: unknown, fallback = 0) => clamp(Number.isFinite(Number(value)) ? Number(value) : fallback, 0, 100) / 100; +const finitePositive = (value: unknown) => + Number.isFinite(Number(value)) && Number(value) > 0; +const finiteNonNegative = (value: unknown) => + Number.isFinite(Number(value)) && Number(value) >= 0; +const clamp = (value: number, low: number, high: number) => + Math.min(high, Math.max(low, value)); +const percent = (value: unknown, fallback = 0) => + clamp(Number.isFinite(Number(value)) ? Number(value) : fallback, 0, 100) / + 100; -function resolveAssumptions(base: CombatAssumptions, effectId: string): ResolvedAssumptions { - const override = base.perEffect?.[effectId] ?? {}; - return { - mode: override.mode ?? base.mode, - hitRate: percent(override.hitRate ?? base.hitRate, 100), - headshotRate: percent(override.headshotRate ?? base.headshotRate, 0), - firingActivity: percent(override.firingActivity ?? base.firingActivity, 100), - encounterSeconds: Number(override.encounterSeconds ?? base.encounterSeconds ?? 0) - }; +function resolveAssumptions( + base: CombatAssumptions, + effectId: string, +): ResolvedAssumptions { + const override = base.perEffect?.[effectId] ?? {}; + return { + mode: override.mode ?? base.mode, + hitRate: percent(override.hitRate ?? base.hitRate, 100), + headshotRate: percent(override.headshotRate ?? base.headshotRate, 0), + firingActivity: percent( + override.firingActivity ?? base.firingActivity, + 100, + ), + encounterSeconds: Number( + override.encounterSeconds ?? base.encounterSeconds ?? 0, + ), + }; } function transformedValue(raw: number, binding: AttributeBinding) { - const weight = Number.isFinite(binding.weight) ? Number(binding.weight) : 1; - const sign = raw < 0 ? -1 : 1; - let value = Math.abs(raw * weight); - const softCap = finiteNonNegative(binding.softCap) ? Number(binding.softCap) : null; - const hardCap = finiteNonNegative(binding.hardCap) ? Number(binding.hardCap) : null; - if (softCap !== null && value > softCap && binding.curveType && binding.curveType !== 'none') { - const parameter = Math.max(0.01, Number(binding.curveParameter ?? 1)); - const overflow = value - softCap; - if (binding.curveType === 'power') { - value = softCap + overflow * Math.pow(softCap / Math.max(softCap + overflow, EPSILON), parameter); - } else { - const ceiling = hardCap ?? Math.max(softCap * 2, softCap + 1); - const t = clamp(overflow / Math.max(ceiling - softCap, EPSILON), 0, 1); - value = softCap + (ceiling - softCap) * (t * t * (3 - 2 * t)); - } - } - if (hardCap !== null) value = Math.min(value, hardCap); - return value * sign; + const weight = Number.isFinite(binding.weight) ? Number(binding.weight) : 1; + const sign = raw < 0 ? -1 : 1; + let value = Math.abs(raw * weight); + const softCap = finiteNonNegative(binding.softCap) + ? Number(binding.softCap) + : null; + const hardCap = finiteNonNegative(binding.hardCap) + ? Number(binding.hardCap) + : null; + if ( + softCap !== null && + value > softCap && + binding.curveType && + binding.curveType !== 'none' + ) { + const parameter = Math.max(0.01, Number(binding.curveParameter ?? 1)); + const overflow = value - softCap; + if (binding.curveType === 'power') { + value = + softCap + + overflow * + Math.pow(softCap / Math.max(softCap + overflow, EPSILON), parameter); + } else { + const ceiling = hardCap ?? Math.max(softCap * 2, softCap + 1); + const t = clamp(overflow / Math.max(ceiling - softCap, EPSILON), 0, 1); + value = softCap + (ceiling - softCap) * (t * t * (3 - 2 * t)); + } + } + if (hardCap !== null) value = Math.min(value, hardCap); + return value * sign; } -function addAttributes(target: StatValues, incoming: StatValues, multiplier = 1) { - for (const [id, value] of Object.entries(incoming)) { - if (Number.isFinite(value)) target[id] = (target[id] ?? 0) + value * multiplier; - } +function addAttributes( + target: StatValues, + incoming: StatValues, + multiplier = 1, +) { + for (const [id, value] of Object.entries(incoming)) { + if (Number.isFinite(value)) + target[id] = (target[id] ?? 0) + value * multiplier; + } } function requiredData(input: CombatCalculationInput): MissingRequirement[] { - const missing: MissingRequirement[] = []; - if (!finitePositive(input.weapon.baseDamage)) missing.push('weapon.baseDamage'); - if (!finitePositive(input.weapon.rpm)) missing.push('weapon.rpm'); - if (!Number.isInteger(input.weapon.magazine) || Number(input.weapon.magazine) < 1) missing.push('weapon.magazine'); - if (!finiteNonNegative(input.weapon.reloadSeconds)) missing.push('weapon.reloadSeconds'); - const boundIds = new Set(input.bindings.map((binding) => binding.attributeId)); - for (const binding of input.bindings) { - if (!Number.isFinite(input.attributes[binding.attributeId] ?? 0)) missing.push(`binding:${binding.attributeId}`); - } - if (input.assumptions.mode === 'encounter') { - if (!finitePositive(input.assumptions.hitRate)) missing.push('assumptions.hitRate'); - if (!finitePositive(input.assumptions.firingActivity)) missing.push('assumptions.firingActivity'); - if (!finitePositive(input.assumptions.encounterSeconds)) missing.push('assumptions.encounterSeconds'); - } - for (const effect of input.stackingEffects ?? []) { - const assumptions = resolveAssumptions(input.assumptions, effect.id); - if (assumptions.mode === 'encounter') { - const override = input.assumptions.perEffect?.[effect.id]; - if (!finitePositive(override?.hitRate ?? input.assumptions.hitRate)) missing.push('assumptions.hitRate'); - if (!finitePositive(override?.firingActivity ?? input.assumptions.firingActivity)) missing.push('assumptions.firingActivity'); - if (!finitePositive(override?.encounterSeconds ?? input.assumptions.encounterSeconds)) missing.push('assumptions.encounterSeconds'); - } - if (assumptions.mode !== 'off') for (const id of Object.keys(effect.attributes)) { - if (!boundIds.has(id)) missing.push(`binding:${id}`); - } - } - return [...new Set(missing)]; + const missing: MissingRequirement[] = []; + if (!finitePositive(input.weapon.baseDamage)) + missing.push('weapon.baseDamage'); + if (!finitePositive(input.weapon.rpm)) missing.push('weapon.rpm'); + if ( + !Number.isInteger(input.weapon.magazine) || + Number(input.weapon.magazine) < 1 + ) + missing.push('weapon.magazine'); + if (!finiteNonNegative(input.weapon.reloadSeconds)) + missing.push('weapon.reloadSeconds'); + const boundIds = new Set( + input.bindings.map((binding) => binding.attributeId), + ); + for (const binding of input.bindings) { + if (!Number.isFinite(input.attributes[binding.attributeId] ?? 0)) + missing.push(`binding:${binding.attributeId}`); + } + if (input.assumptions.mode === 'encounter') { + if (!finitePositive(input.assumptions.hitRate)) + missing.push('assumptions.hitRate'); + if (!finitePositive(input.assumptions.firingActivity)) + missing.push('assumptions.firingActivity'); + if (!finitePositive(input.assumptions.encounterSeconds)) + missing.push('assumptions.encounterSeconds'); + } + for (const effect of input.stackingEffects ?? []) { + const assumptions = resolveAssumptions(input.assumptions, effect.id); + if (assumptions.mode === 'encounter') { + const override = input.assumptions.perEffect?.[effect.id]; + if (!finitePositive(override?.hitRate ?? input.assumptions.hitRate)) + missing.push('assumptions.hitRate'); + if ( + !finitePositive( + override?.firingActivity ?? input.assumptions.firingActivity, + ) + ) + missing.push('assumptions.firingActivity'); + if ( + !finitePositive( + override?.encounterSeconds ?? input.assumptions.encounterSeconds, + ) + ) + missing.push('assumptions.encounterSeconds'); + } + if (assumptions.mode !== 'off') + for (const id of Object.keys(effect.attributes)) { + if (!boundIds.has(id)) missing.push(`binding:${id}`); + } + } + return [...new Set(missing)]; } -function stageValues(attributes: StatValues, bindings: AttributeBinding[], stage: CalculationStage) { - let additive = 0; - let multiplicative = 1; - for (const binding of bindings.filter((item) => item.stage === stage)) { - const value = transformedValue(Number(attributes[binding.attributeId] ?? 0), binding); - if (binding.operator === 'add') additive += value; - else multiplicative *= value; - } - return { additive, multiplicative }; +function stageValues( + attributes: StatValues, + bindings: AttributeBinding[], + stage: CalculationStage, +) { + let additive = 0; + let multiplicative = 1; + for (const binding of bindings.filter((item) => item.stage === stage)) { + const value = transformedValue( + Number(attributes[binding.attributeId] ?? 0), + binding, + ); + if (binding.operator === 'add') additive += value; + else multiplicative *= value; + } + return { additive, multiplicative }; } function damageBase( - weapon: Required, - attributes: StatValues, - bindings: AttributeBinding[], - stageOrder?: CalculationStage[] + weapon: Required, + attributes: StatValues, + bindings: AttributeBinding[], + stageOrder?: CalculationStage[], ) { - let damage = weapon.baseDamage; - const order = stageOrder?.length - ? stageOrder - : ['flat_damage', 'weapon_damage', 'final_damage'] satisfies CalculationStage[]; - for (const stage of order) { - if (!['flat_damage', 'weapon_damage', 'final_damage'].includes(stage)) continue; - const values = stageValues(attributes, bindings, stage); - if (stage === 'flat_damage') damage = (damage + values.additive) * values.multiplicative; - else damage *= (1 + values.additive / 100) * values.multiplicative; - } - return damage; + let damage = weapon.baseDamage; + const order = stageOrder?.length + ? stageOrder + : ([ + 'flat_damage', + 'weapon_damage', + 'final_damage', + ] satisfies CalculationStage[]); + for (const stage of order) { + if (!['flat_damage', 'weapon_damage', 'final_damage'].includes(stage)) + continue; + const values = stageValues(attributes, bindings, stage); + if (stage === 'flat_damage') + damage = (damage + values.additive) * values.multiplicative; + else damage *= (1 + values.additive / 100) * values.multiplicative; + } + return damage; } function hitValues( - weapon: Required, - attributes: StatValues, - bindings: AttributeBinding[], - stageOrder?: CalculationStage[] + weapon: Required, + attributes: StatValues, + bindings: AttributeBinding[], + stageOrder?: CalculationStage[], ): DamageValues { - const normal = damageBase(weapon, attributes, bindings, stageOrder); - const critical = stageValues(attributes, bindings, 'critical_damage'); - const headshot = stageValues(attributes, bindings, 'headshot_damage'); - const criticalFactor = (1 + critical.additive / 100) * critical.multiplicative; - const headshotFactor = weapon.headshotMultiplier * (1 + headshot.additive / 100) * headshot.multiplicative; - return { - normal, - critical: normal * criticalFactor, - headshot: normal * headshotFactor, - criticalHeadshot: normal * criticalFactor * headshotFactor - }; + const normal = damageBase(weapon, attributes, bindings, stageOrder); + const critical = stageValues(attributes, bindings, 'critical_damage'); + const headshot = stageValues(attributes, bindings, 'headshot_damage'); + const criticalFactor = + (1 + critical.additive / 100) * critical.multiplicative; + const headshotFactor = + weapon.headshotMultiplier * + (1 + headshot.additive / 100) * + headshot.multiplicative; + return { + normal, + critical: normal * criticalFactor, + headshot: normal * headshotFactor, + criticalHeadshot: normal * criticalFactor * headshotFactor, + }; } function dpsFor( - weapon: Required, - attributes: StatValues, - bindings: AttributeBinding[], - assumptions: ResolvedAssumptions, - stageOrder?: CalculationStage[] + weapon: Required, + attributes: StatValues, + bindings: AttributeBinding[], + assumptions: ResolvedAssumptions, + stageOrder?: CalculationStage[], ) { - const values = hitValues(weapon, attributes, bindings, stageOrder); - const critical = stageValues(attributes, bindings, 'critical_chance'); - const boundCriticalChance = clamp(critical.additive / 100, 0, 1); - const configuredHeadshot = stageValues(attributes, bindings, 'headshot_chance'); - const boundHeadshotChance = clamp(configuredHeadshot.additive / 100, 0, 1); - const headshotChance = assumptions.headshotRate || boundHeadshotChance; - const average = values.normal * (1 - boundCriticalChance) * (1 - headshotChance) - + values.critical * boundCriticalChance * (1 - headshotChance) - + values.headshot * (1 - boundCriticalChance) * headshotChance - + values.criticalHeadshot * boundCriticalChance * headshotChance; - const { rpm, magazine, reloadSeconds } = resolveWeaponFiringCycle( - weapon, - attributes, - bindings - ); - const shotsPerSecond = rpm / 60; - return { - values, - burst: average * shotsPerSecond, - sustained: average * magazine / (magazine / shotsPerSecond + reloadSeconds) - }; + const values = hitValues(weapon, attributes, bindings, stageOrder); + const critical = stageValues(attributes, bindings, 'critical_chance'); + const boundCriticalChance = clamp(critical.additive / 100, 0, 1); + const configuredHeadshot = stageValues( + attributes, + bindings, + 'headshot_chance', + ); + const boundHeadshotChance = clamp(configuredHeadshot.additive / 100, 0, 1); + const headshotChance = assumptions.headshotRate || boundHeadshotChance; + const average = + values.normal * (1 - boundCriticalChance) * (1 - headshotChance) + + values.critical * boundCriticalChance * (1 - headshotChance) + + values.headshot * (1 - boundCriticalChance) * headshotChance + + values.criticalHeadshot * boundCriticalChance * headshotChance; + const { rpm, magazine, reloadSeconds } = resolveWeaponFiringCycle( + weapon, + attributes, + bindings, + ); + const shotsPerSecond = rpm / 60; + return { + values, + burst: average * shotsPerSecond, + sustained: + (average * magazine) / (magazine / shotsPerSecond + reloadSeconds), + }; } export function expectedWeaponShotDamage( - weapon: WeaponCombatInput, - attributes: StatValues, - bindings: AttributeBinding[], - headshot = false, - stageOrder?: CalculationStage[] + weapon: WeaponCombatInput, + attributes: StatValues, + bindings: AttributeBinding[], + headshot = false, + stageOrder?: CalculationStage[], ) { - const resolvedWeapon: Required = { - baseDamage: Number(weapon.baseDamage), - rpm: Number(weapon.rpm), - magazine: Number(weapon.magazine), - reloadSeconds: Number(weapon.reloadSeconds), - headshotMultiplier: Number(weapon.headshotMultiplier ?? 2) - }; - const values = hitValues(resolvedWeapon, attributes, bindings, stageOrder); - const critical = stageValues(attributes, bindings, 'critical_chance'); - const criticalChance = clamp(critical.additive / 100, 0, 1); - return headshot - ? values.headshot * (1 - criticalChance) + values.criticalHeadshot * criticalChance - : values.normal * (1 - criticalChance) + values.critical * criticalChance; + const resolvedWeapon: Required = { + baseDamage: Number(weapon.baseDamage), + rpm: Number(weapon.rpm), + magazine: Number(weapon.magazine), + reloadSeconds: Number(weapon.reloadSeconds), + headshotMultiplier: Number(weapon.headshotMultiplier ?? 2), + }; + const values = hitValues(resolvedWeapon, attributes, bindings, stageOrder); + const critical = stageValues(attributes, bindings, 'critical_chance'); + const criticalChance = clamp(critical.additive / 100, 0, 1); + return headshot + ? values.headshot * (1 - criticalChance) + + values.criticalHeadshot * criticalChance + : values.normal * (1 - criticalChance) + values.critical * criticalChance; } export function resolveWeaponFiringCycle( - weapon: WeaponCombatInput, - attributes: StatValues, - bindings: AttributeBinding[] + weapon: WeaponCombatInput, + attributes: StatValues, + bindings: AttributeBinding[], ): WeaponFiringCycle { - const rateOfFire = stageValues(attributes, bindings, 'rate_of_fire'); - const magazineSize = stageValues(attributes, bindings, 'magazine_size'); - const reloadSpeed = stageValues(attributes, bindings, 'reload_speed'); - return { - rpm: Number(weapon.rpm) - * (1 + rateOfFire.additive / 100) - * rateOfFire.multiplicative, - magazine: Math.max( - 1, - Number(weapon.magazine) - * (1 + magazineSize.additive / 100) - * magazineSize.multiplicative - ), - reloadSeconds: Number(weapon.reloadSeconds) / Math.max( - EPSILON, - (1 + reloadSpeed.additive / 100) * reloadSpeed.multiplicative - ) - }; + const rateOfFire = stageValues(attributes, bindings, 'rate_of_fire'); + const magazineSize = stageValues(attributes, bindings, 'magazine_size'); + const reloadSpeed = stageValues(attributes, bindings, 'reload_speed'); + return { + rpm: + Number(weapon.rpm) * + (1 + rateOfFire.additive / 100) * + rateOfFire.multiplicative, + magazine: Math.max( + 1, + Number(weapon.magazine) * + (1 + magazineSize.additive / 100) * + magazineSize.multiplicative, + ), + reloadSeconds: + Number(weapon.reloadSeconds) / + Math.max( + EPSILON, + (1 + reloadSpeed.additive / 100) * reloadSpeed.multiplicative, + ), + }; } -function triggerEvents(weapon: Required, effect: StackingEffect, assumptions: ResolvedAssumptions) { - if (assumptions.mode !== 'encounter' || effect.trigger.hitsRequired < 1 || effect.trigger.windowSeconds <= 0) return []; - const shotInterval = (60 / weapon.rpm) / Math.max(assumptions.firingActivity, EPSILON); - let magazineRemaining = weapon.magazine; - let expectedHits = 0; - let counterStartedAt = 0; - const events: number[] = []; - for (let time = 0; time <= assumptions.encounterSeconds + EPSILON;) { - if (magazineRemaining === 0) { - time += weapon.reloadSeconds; - magazineRemaining = weapon.magazine; - if (time - counterStartedAt > effect.trigger.windowSeconds) { - expectedHits = 0; - counterStartedAt = time; - } - continue; - } - if (expectedHits === 0) counterStartedAt = time; - expectedHits += assumptions.hitRate; - magazineRemaining -= 1; - if (time - counterStartedAt > effect.trigger.windowSeconds) { - expectedHits = assumptions.hitRate; - counterStartedAt = time; - } - if (expectedHits + EPSILON >= effect.trigger.hitsRequired) { - events.push(time); - expectedHits = 0; - counterStartedAt = time; - } - time += shotInterval; - } - return events; +function triggerEvents( + weapon: Required, + effect: StackingEffect, + assumptions: ResolvedAssumptions, +) { + if ( + assumptions.mode !== 'encounter' || + effect.trigger.hitsRequired < 1 || + effect.trigger.windowSeconds <= 0 + ) + return []; + const shotInterval = + 60 / weapon.rpm / Math.max(assumptions.firingActivity, EPSILON); + let magazineRemaining = weapon.magazine; + let expectedHits = 0; + let counterStartedAt = 0; + const events: number[] = []; + for (let time = 0; time <= assumptions.encounterSeconds + EPSILON;) { + if (magazineRemaining === 0) { + time += weapon.reloadSeconds; + magazineRemaining = weapon.magazine; + if (time - counterStartedAt > effect.trigger.windowSeconds) { + expectedHits = 0; + counterStartedAt = time; + } + continue; + } + if (expectedHits === 0) counterStartedAt = time; + expectedHits += assumptions.hitRate; + magazineRemaining -= 1; + if (time - counterStartedAt > effect.trigger.windowSeconds) { + expectedHits = assumptions.hitRate; + counterStartedAt = time; + } + if (expectedHits + EPSILON >= effect.trigger.hitsRequired) { + events.push(time); + expectedHits = 0; + counterStartedAt = time; + } + time += shotInterval; + } + return events; } function stateAt(effect: TimedStackEffect, time: number): StackState { - if (effect.assumptions.mode === 'off') { - return { stacks: 0, expiresAt: 0, maxStacksReached: 0, buildUpSeconds: null }; - } - if (effect.assumptions.mode === 'peak') { - return { - stacks: effect.effect.lifecycle.maxStacks, - expiresAt: Infinity, - maxStacksReached: effect.effect.lifecycle.maxStacks, - buildUpSeconds: 0 - }; - } - const state: StackState = { stacks: 0, expiresAt: 0, maxStacksReached: 0, buildUpSeconds: null }; - for (const eventTime of effect.events) { - if (eventTime - time > EPSILON) break; - if (eventTime - state.expiresAt > EPSILON) state.stacks = 0; - state.stacks = Math.min( - effect.effect.lifecycle.maxStacks, - state.stacks + Math.max(1, effect.effect.lifecycle.stacksPerTrigger ?? 1) - ); - state.expiresAt = eventTime + effect.effect.lifecycle.durationSeconds; - state.maxStacksReached = Math.max(state.maxStacksReached, state.stacks); - if (state.stacks === effect.effect.lifecycle.maxStacks && state.buildUpSeconds === null) { - state.buildUpSeconds = eventTime; - } - } - if (time - state.expiresAt > EPSILON) state.stacks = 0; - return state; + if (effect.assumptions.mode === 'off') { + return { + stacks: 0, + expiresAt: 0, + maxStacksReached: 0, + buildUpSeconds: null, + }; + } + if (effect.assumptions.mode === 'peak') { + return { + stacks: effect.effect.lifecycle.maxStacks, + expiresAt: Infinity, + maxStacksReached: effect.effect.lifecycle.maxStacks, + buildUpSeconds: 0, + }; + } + const state: StackState = { + stacks: 0, + expiresAt: 0, + maxStacksReached: 0, + buildUpSeconds: null, + }; + for (const eventTime of effect.events) { + if (eventTime - time > EPSILON) break; + if (eventTime - state.expiresAt > EPSILON) state.stacks = 0; + state.stacks = Math.min( + effect.effect.lifecycle.maxStacks, + state.stacks + Math.max(1, effect.effect.lifecycle.stacksPerTrigger ?? 1), + ); + state.expiresAt = eventTime + effect.effect.lifecycle.durationSeconds; + state.maxStacksReached = Math.max(state.maxStacksReached, state.stacks); + if ( + state.stacks === effect.effect.lifecycle.maxStacks && + state.buildUpSeconds === null + ) { + state.buildUpSeconds = eventTime; + } + } + if (time - state.expiresAt > EPSILON) state.stacks = 0; + return state; } -function stackAttributes(base: StatValues, effects: TimedStackEffect[], time: number) { - const result = { ...base }; - for (const effect of effects) addAttributes(result, effect.effect.attributes, stateAt(effect, time).stacks); - return result; +function stackAttributes( + base: StatValues, + effects: TimedStackEffect[], + time: number, +) { + const result = { ...base }; + for (const effect of effects) + addAttributes( + result, + effect.effect.attributes, + stateAt(effect, time).stacks, + ); + return result; } -export function calculateCombat(input: CombatCalculationInput): CombatCalculation { - const missing = requiredData(input); - if (missing.length) { - return { - status: 'INSUFFICIENT_DATA', - missing, - hitValues: null, - burstDps: null, - sustainedDps: null, - encounterDps: null, - stackSummaries: [] - }; - } - const weapon: Required = { - baseDamage: Number(input.weapon.baseDamage), - rpm: Number(input.weapon.rpm), - magazine: Number(input.weapon.magazine), - reloadSeconds: Number(input.weapon.reloadSeconds), - headshotMultiplier: Number(input.weapon.headshotMultiplier ?? 2) - }; - const effects: TimedStackEffect[] = (input.stackingEffects ?? []).map((effect) => { - const assumptions = resolveAssumptions(input.assumptions, effect.id); - return { effect, assumptions, events: triggerEvents(weapon, effect, assumptions) }; - }); - const globalAssumptions = resolveAssumptions(input.assumptions, ''); - const finalTime = Math.max( - globalAssumptions.encounterSeconds, - ...effects.filter((effect) => effect.assumptions.mode === 'encounter').map((effect) => effect.assumptions.encounterSeconds) - ); - const current = dpsFor( - weapon, - stackAttributes(input.attributes, effects, finalTime), - input.bindings, - globalAssumptions, - input.stageOrder - ); - let encounterDps: number | null = null; - if (effects.some((effect) => effect.assumptions.mode === 'encounter') && finalTime > 0) { - const steps = Math.max(30, Math.min(600, Math.ceil(finalTime * 4))); - let total = 0; - for (let index = 0; index < steps; index += 1) { - const time = finalTime * (index + 0.5) / steps; - const result = dpsFor( - weapon, - stackAttributes(input.attributes, effects, time), - input.bindings, - globalAssumptions, - input.stageOrder - ); - total += result.sustained * globalAssumptions.firingActivity; - } - encounterDps = total / steps; - } - return { - status: 'OK', - missing: [], - hitValues: current.values, - burstDps: current.burst, - sustainedDps: current.sustained, - encounterDps, - stackSummaries: effects.map((item) => { - const end = stateAt(item, finalTime); - const duration = item.assumptions.mode === 'encounter' ? item.assumptions.encounterSeconds : 0; - const samples = item.assumptions.mode === 'encounter' && duration > 0 ? 100 : 1; - let sum = 0; - for (let index = 0; index < samples; index += 1) { - sum += stateAt(item, duration * (index + 0.5) / samples).stacks; - } - return { - effectId: item.effect.id, - mode: item.assumptions.mode, - stacksAtEnd: end.stacks, - maxStacksReached: end.maxStacksReached, - buildUpSeconds: end.buildUpSeconds, - averageStacks: sum / samples, - maintained: end.maxStacksReached === item.effect.lifecycle.maxStacks - && (item.assumptions.mode === 'peak' || end.stacks > 0) - }; - }) - }; +export function calculateCombat( + input: CombatCalculationInput, +): CombatCalculation { + const missing = requiredData(input); + if (missing.length) { + return { + status: 'INSUFFICIENT_DATA', + missing, + hitValues: null, + burstDps: null, + sustainedDps: null, + encounterDps: null, + stackSummaries: [], + }; + } + const weapon: Required = { + baseDamage: Number(input.weapon.baseDamage), + rpm: Number(input.weapon.rpm), + magazine: Number(input.weapon.magazine), + reloadSeconds: Number(input.weapon.reloadSeconds), + headshotMultiplier: Number(input.weapon.headshotMultiplier ?? 2), + }; + const effects: TimedStackEffect[] = (input.stackingEffects ?? []).map( + (effect) => { + const assumptions = resolveAssumptions(input.assumptions, effect.id); + return { + effect, + assumptions, + events: triggerEvents(weapon, effect, assumptions), + }; + }, + ); + const globalAssumptions = resolveAssumptions(input.assumptions, ''); + const finalTime = Math.max( + globalAssumptions.encounterSeconds, + ...effects + .filter((effect) => effect.assumptions.mode === 'encounter') + .map((effect) => effect.assumptions.encounterSeconds), + ); + const current = dpsFor( + weapon, + stackAttributes(input.attributes, effects, finalTime), + input.bindings, + globalAssumptions, + input.stageOrder, + ); + let encounterDps: number | null = null; + if ( + effects.some((effect) => effect.assumptions.mode === 'encounter') && + finalTime > 0 + ) { + const steps = Math.max(30, Math.min(600, Math.ceil(finalTime * 4))); + let total = 0; + for (let index = 0; index < steps; index += 1) { + const time = (finalTime * (index + 0.5)) / steps; + const result = dpsFor( + weapon, + stackAttributes(input.attributes, effects, time), + input.bindings, + globalAssumptions, + input.stageOrder, + ); + total += result.sustained * globalAssumptions.firingActivity; + } + encounterDps = total / steps; + } + return { + status: 'OK', + missing: [], + hitValues: current.values, + burstDps: current.burst, + sustainedDps: current.sustained, + encounterDps, + stackSummaries: effects.map((item) => { + const end = stateAt(item, finalTime); + const duration = + item.assumptions.mode === 'encounter' + ? item.assumptions.encounterSeconds + : 0; + const samples = + item.assumptions.mode === 'encounter' && duration > 0 ? 100 : 1; + let sum = 0; + for (let index = 0; index < samples; index += 1) { + sum += stateAt(item, (duration * (index + 0.5)) / samples).stacks; + } + return { + effectId: item.effect.id, + mode: item.assumptions.mode, + stacksAtEnd: end.stacks, + maxStacksReached: end.maxStacksReached, + buildUpSeconds: end.buildUpSeconds, + averageStacks: sum / samples, + maintained: + end.maxStacksReached === item.effect.lifecycle.maxStacks && + (item.assumptions.mode === 'peak' || end.stacks > 0), + }; + }), + }; } diff --git a/src/lib/calculations/conditional.test.ts b/src/lib/calculations/conditional.test.ts index 9d43e46..eb2b805 100644 --- a/src/lib/calculations/conditional.test.ts +++ b/src/lib/calculations/conditional.test.ts @@ -1,413 +1,571 @@ import { describe, expect, it } from 'vitest'; import { - TRIGGER_ACTION_REGISTRY, - evaluateConditionalProgram, - summarizeTrigger, - validateConditionalProgram, - validateTriggerConfiguration, - type ConditionalEvent, - type ConditionalProgram, - type TriggerConfiguration + TRIGGER_ACTION_REGISTRY, + evaluateConditionalProgram, + summarizeTrigger, + validateConditionalProgram, + validateTriggerConfiguration, + type ConditionalEvent, + type ConditionalProgram, + type TriggerConfiguration, } from './conditional.js'; const modifier = { - id: 'weapon-damage', - scope: 'equipped_weapon' as const, - application: 'active_modifier' as const, - attributeId: 'weapon-damage', - value: 3 + id: 'weapon-damage', + scope: 'equipped_weapon' as const, + application: 'active_modifier' as const, + attributeId: 'weapon-damage', + value: 3, }; const program = ( - trigger: TriggerConfiguration, - overrides: Partial = {} + trigger: TriggerConfiguration, + overrides: Partial = {}, ): ConditionalProgram => ({ - id: 'test-effect', - trigger, - lifecycle: { - cooldownSeconds: 0, - durationSeconds: 10, - maxStacks: 10, - stacksPerActivation: 1 - }, - outcomes: [modifier], - ...overrides + id: 'test-effect', + trigger, + lifecycle: { + cooldownSeconds: 0, + durationSeconds: 10, + maxStacks: 10, + stacksPerActivation: 1, + }, + outcomes: [modifier], + ...overrides, }); const shot = ( - at: number, - targetId: string | null, - hit = true, - headshot = false + at: number, + targetId: string | null, + hit = true, + headshot = false, ): ConditionalEvent => ({ kind: 'shot', at, targetId, hit, headshot }); describe('trigger action registry', () => { - it('owns every action and validates discriminated parameters', () => { - expect(Object.keys(TRIGGER_ACTION_REGISTRY)).toHaveLength(16); - expect(validateTriggerConfiguration('same_target_hits_within_window', { - hitsRequired: 9, - windowSeconds: 3 - })).toMatchObject({ ok: true }); - expect(validateTriggerConfiguration('same_target_hits_within_window', { - hitsRequired: 0, - windowSeconds: -1 - })).toEqual({ - ok: false, - errors: ['Hits required must be a positive integer.', 'Window seconds must be greater than 0.'] - }); - expect(validateTriggerConfiguration('while_firing', { unexpected: true })).toMatchObject({ ok: false }); - expect(validateTriggerConfiguration('target_hit_within_distance', { meters: 0 })).toEqual({ - ok: false, - errors: ['Distance must be greater than 0.'] - }); - }); + it('owns every action and validates discriminated parameters', () => { + expect(Object.keys(TRIGGER_ACTION_REGISTRY)).toHaveLength(16); + expect( + validateTriggerConfiguration('same_target_hits_within_window', { + hitsRequired: 9, + windowSeconds: 3, + }), + ).toMatchObject({ ok: true }); + expect( + validateTriggerConfiguration('same_target_hits_within_window', { + hitsRequired: 0, + windowSeconds: -1, + }), + ).toEqual({ + ok: false, + errors: [ + 'Hits required must be a positive integer.', + 'Window seconds must be greater than 0.', + ], + }); + expect( + validateTriggerConfiguration('while_firing', { unexpected: true }), + ).toMatchObject({ ok: false }); + expect( + validateTriggerConfiguration('target_hit_within_distance', { meters: 0 }), + ).toEqual({ + ok: false, + errors: ['Distance must be greater than 0.'], + }); + }); - it('generates user-facing summaries from typed values', () => { - expect(summarizeTrigger({ - action: 'distinct_enemies_within_window', - params: { enemiesRequired: 4, windowSeconds: 2.5 } - })).toBe('Hit 4 distinct enemies within 2.5 seconds.'); - expect(summarizeTrigger({ - action: 'continuous_firing_duration', - params: { seconds: 1 } - })).toBe('Fire continuously for 1 second.'); - expect(summarizeTrigger({ - action: 'target_hit_within_distance', - params: { meters: 20 } - })).toBe('Hit a target within 20 meters.'); - }); + it('generates user-facing summaries from typed values', () => { + expect( + summarizeTrigger({ + action: 'distinct_enemies_within_window', + params: { enemiesRequired: 4, windowSeconds: 2.5 }, + }), + ).toBe('Hit 4 distinct enemies within 2.5 seconds.'); + expect( + summarizeTrigger({ + action: 'continuous_firing_duration', + params: { seconds: 1 }, + }), + ).toBe('Fire continuously for 1 second.'); + expect( + summarizeTrigger({ + action: 'target_hit_within_distance', + params: { meters: 20 }, + }), + ).toBe('Hit a target within 20 meters.'); + }); - it('validates unique health bands and their outcome relationships', () => { - const result = validateConditionalProgram(program({ - action: 'health_threshold_bands', - params: { - bands: [ - { thresholdPercent: 50, outcomeKey: 'low' }, - { thresholdPercent: 20, outcomeKey: 'critical' } - ] - } - }, { - outcomes: [ - { ...modifier, id: 'low', outcomeKey: 'low' }, - { ...modifier, id: 'critical', outcomeKey: 'critical', value: 10 } - ] - })); - expect(result).toMatchObject({ ok: true }); - }); + it('validates unique health bands and their outcome relationships', () => { + const result = validateConditionalProgram( + program( + { + action: 'health_threshold_bands', + params: { + bands: [ + { thresholdPercent: 50, outcomeKey: 'low' }, + { thresholdPercent: 20, outcomeKey: 'critical' }, + ], + }, + }, + { + outcomes: [ + { ...modifier, id: 'low', outcomeKey: 'low' }, + { ...modifier, id: 'critical', outcomeKey: 'critical', value: 10 }, + ], + }, + ), + ); + expect(result).toMatchObject({ ok: true }); + }); - it('rejects negative instant outcomes but permits negative Attribute modifiers', () => { - expect(validateConditionalProgram(program({ - action: 'headshot', - params: {} - }, { - outcomes: [{ - id: 'damage', - scope: 'target', - application: 'direct_damage', - basis: 'flat', - value: -10 - }] - }))).toEqual({ - ok: false, - errors: ['Instant outcome "damage" cannot use a negative value.'] - }); - expect(validateConditionalProgram(program({ - action: 'headshot', - params: {} - }, { - outcomes: [{ ...modifier, value: -10 }] - }))).toMatchObject({ ok: true }); - }); + it('rejects negative instant outcomes but permits negative Attribute modifiers', () => { + expect( + validateConditionalProgram( + program( + { + action: 'headshot', + params: {}, + }, + { + outcomes: [ + { + id: 'damage', + scope: 'target', + application: 'direct_damage', + basis: 'flat', + value: -10, + }, + ], + }, + ), + ), + ).toEqual({ + ok: false, + errors: ['Instant outcome "damage" cannot use a negative value.'], + }); + expect( + validateConditionalProgram( + program( + { + action: 'headshot', + params: {}, + }, + { + outcomes: [{ ...modifier, value: -10 }], + }, + ), + ), + ).toMatchObject({ ok: true }); + }); }); describe('evaluateConditionalProgram', () => { - it('counts repeated hits against the same target inside the window', () => { - const result = evaluateConditionalProgram(program({ - action: 'same_target_hits_within_window', - params: { hitsRequired: 3, windowSeconds: 1 } - }), [ - shot(0, 'alpha'), - shot(0.2, 'bravo'), - shot(0.4, 'alpha'), - shot(0.8, 'alpha') - ], { mode: 'encounter', encounterSeconds: 5 }); - expect(result).toMatchObject({ - status: 'OK', - activationCount: 1, - maxStacksReached: 1 - }); - expect(result.activations[0]).toMatchObject({ at: 0.8, stacksBefore: 0, stacksAfter: 1 }); - }); + it('counts repeated hits against the same target inside the window', () => { + const result = evaluateConditionalProgram( + program({ + action: 'same_target_hits_within_window', + params: { hitsRequired: 3, windowSeconds: 1 }, + }), + [ + shot(0, 'alpha'), + shot(0.2, 'bravo'), + shot(0.4, 'alpha'), + shot(0.8, 'alpha'), + ], + { mode: 'encounter', encounterSeconds: 5 }, + ); + expect(result).toMatchObject({ + status: 'OK', + activationCount: 1, + maxStacksReached: 1, + }); + expect(result.activations[0]).toMatchObject({ + at: 0.8, + stacksBefore: 0, + stacksAfter: 1, + }); + }); - it('counts each distinct enemy only once per window', () => { - const result = evaluateConditionalProgram(program({ - action: 'distinct_enemies_within_window', - params: { enemiesRequired: 3, windowSeconds: 2 } - }), [ - shot(0, 'alpha'), - shot(0.2, 'alpha'), - shot(0.4, 'bravo'), - shot(0.6, 'charlie') - ], { mode: 'encounter', encounterSeconds: 5 }); - expect(result.activationCount).toBe(1); - expect(result.activations[0].at).toBe(0.6); - }); + it('counts each distinct enemy only once per window', () => { + const result = evaluateConditionalProgram( + program({ + action: 'distinct_enemies_within_window', + params: { enemiesRequired: 3, windowSeconds: 2 }, + }), + [ + shot(0, 'alpha'), + shot(0.2, 'alpha'), + shot(0.4, 'bravo'), + shot(0.6, 'charlie'), + ], + { mode: 'encounter', encounterSeconds: 5 }, + ); + expect(result.activationCount).toBe(1); + expect(result.activations[0].at).toBe(0.6); + }); - it('resets consecutive hits after a miss or target change', () => { - const result = evaluateConditionalProgram(program({ - action: 'consecutive_same_target_hits', - params: { hitsRequired: 3 } - }), [ - shot(0, 'alpha'), - shot(0.1, 'alpha'), - shot(0.2, null, false), - shot(0.3, 'alpha'), - shot(0.4, 'bravo'), - shot(0.5, 'bravo'), - shot(0.6, 'bravo') - ], { mode: 'encounter', encounterSeconds: 5 }); - expect(result.activationCount).toBe(1); - expect(result.activations[0].at).toBe(0.6); - }); + it('resets consecutive hits after a miss or target change', () => { + const result = evaluateConditionalProgram( + program({ + action: 'consecutive_same_target_hits', + params: { hitsRequired: 3 }, + }), + [ + shot(0, 'alpha'), + shot(0.1, 'alpha'), + shot(0.2, null, false), + shot(0.3, 'alpha'), + shot(0.4, 'bravo'), + shot(0.5, 'bravo'), + shot(0.6, 'bravo'), + ], + { mode: 'encounter', encounterSeconds: 5 }, + ); + expect(result.activationCount).toBe(1); + expect(result.activations[0].at).toBe(0.6); + }); - it('starts cooldown on activation and applies the activation after its triggering event', () => { - const result = evaluateConditionalProgram(program({ - action: 'headshot', - params: {} - }, { - lifecycle: { - cooldownSeconds: 3, - durationSeconds: 5, - maxStacks: 3, - stacksPerActivation: 1 - } - }), [ - shot(1, 'alpha', true, true), - shot(2, 'alpha', true, true), - shot(4, 'alpha', true, true) - ], { mode: 'encounter', encounterSeconds: 10 }); - expect(result.activations).toHaveLength(2); - expect(result.activations.map((activation) => activation.at)).toEqual([1, 4]); - expect(result.activations[0].effectiveAfter).toBeGreaterThan(1); - expect(result.activations[1]).toMatchObject({ - stacksBefore: 1, - stacksAfter: 2, - expiresAt: 9, - cooldownUntil: 7 - }); - }); + it('starts cooldown on activation and applies the activation after its triggering event', () => { + const result = evaluateConditionalProgram( + program( + { + action: 'headshot', + params: {}, + }, + { + lifecycle: { + cooldownSeconds: 3, + durationSeconds: 5, + maxStacks: 3, + stacksPerActivation: 1, + }, + }, + ), + [ + shot(1, 'alpha', true, true), + shot(2, 'alpha', true, true), + shot(4, 'alpha', true, true), + ], + { mode: 'encounter', encounterSeconds: 10 }, + ); + expect(result.activations).toHaveLength(2); + expect(result.activations.map((activation) => activation.at)).toEqual([ + 1, 4, + ]); + expect(result.activations[0].effectiveAfter).toBeGreaterThan(1); + expect(result.activations[1]).toMatchObject({ + stacksBefore: 1, + stacksAfter: 2, + expiresAt: 9, + cooldownUntil: 7, + }); + }); - it('treats an explicit non-headshot hit as a body shot', () => { - const result = evaluateConditionalProgram(program({ - action: 'body_shot', - params: {} - }), [ - shot(1, 'alpha', true, false), - shot(2, 'alpha', true, true), - shot(3, 'alpha', false, false) - ], { mode: 'encounter', encounterSeconds: 5 }); - expect(result.activations.map((activation) => activation.at)).toEqual([1]); - }); + it('treats an explicit non-headshot hit as a body shot', () => { + const result = evaluateConditionalProgram( + program({ + action: 'body_shot', + params: {}, + }), + [ + shot(1, 'alpha', true, false), + shot(2, 'alpha', true, true), + shot(3, 'alpha', false, false), + ], + { mode: 'encounter', encounterSeconds: 5 }, + ); + expect(result.activations.map((activation) => activation.at)).toEqual([1]); + }); - it('refreshes one shared duration and clears every stack on expiry', () => { - const result = evaluateConditionalProgram(program({ - action: 'damage_taken', - params: {} - }, { - lifecycle: { - cooldownSeconds: 0, - durationSeconds: 3, - maxStacks: 3, - stacksPerActivation: 1 - } - }), [ - { kind: 'damage_taken', at: 0 }, - { kind: 'damage_taken', at: 1 }, - { kind: 'damage_taken', at: 5 } - ], { mode: 'encounter', encounterSeconds: 10 }); - expect(result.activations.map(({ stacksBefore, stacksAfter }) => ({ stacksBefore, stacksAfter }))).toEqual([ - { stacksBefore: 0, stacksAfter: 1 }, - { stacksBefore: 1, stacksAfter: 2 }, - { stacksBefore: 0, stacksAfter: 1 } - ]); - expect(result.intervals).toEqual([ - { start: 0.000001, end: 1.000001, stacks: 1, outcomeKey: null }, - { start: 1.000001, end: 4, stacks: 2, outcomeKey: null }, - { start: 5.000001, end: 8, stacks: 1, outcomeKey: null } - ]); - }); + it('refreshes one shared duration and clears every stack on expiry', () => { + const result = evaluateConditionalProgram( + program( + { + action: 'damage_taken', + params: {}, + }, + { + lifecycle: { + cooldownSeconds: 0, + durationSeconds: 3, + maxStacks: 3, + stacksPerActivation: 1, + }, + }, + ), + [ + { kind: 'damage_taken', at: 0 }, + { kind: 'damage_taken', at: 1 }, + { kind: 'damage_taken', at: 5 }, + ], + { mode: 'encounter', encounterSeconds: 10 }, + ); + expect( + result.activations.map(({ stacksBefore, stacksAfter }) => ({ + stacksBefore, + stacksAfter, + })), + ).toEqual([ + { stacksBefore: 0, stacksAfter: 1 }, + { stacksBefore: 1, stacksAfter: 2 }, + { stacksBefore: 0, stacksAfter: 1 }, + ]); + expect(result.intervals).toEqual([ + { start: 0.000001, end: 1.000001, stacks: 1, outcomeKey: null }, + { start: 1.000001, end: 4, stacks: 2, outcomeKey: null }, + { start: 5.000001, end: 8, stacks: 1, outcomeKey: null }, + ]); + }); - it('resets firing progress on reload without removing an active buff', () => { - const result = evaluateConditionalProgram(program({ - action: 'continuous_firing_duration', - params: { seconds: 2 } - }, { - lifecycle: { - cooldownSeconds: 0, - durationSeconds: 10, - maxStacks: 4, - stacksPerActivation: 1 - } - }), [ - { kind: 'firing_started', at: 0 }, - shot(0.5, 'alpha'), - { kind: 'reload_started', at: 3 }, - { kind: 'reload_completed', at: 4 }, - { kind: 'firing_started', at: 4 }, - { kind: 'firing_stopped', at: 7 } - ], { mode: 'encounter', encounterSeconds: 12 }); - expect(result.activations.map((activation) => activation.at)).toEqual([2, 6]); - expect(result.activations[1]).toMatchObject({ stacksBefore: 1, stacksAfter: 2, expiresAt: 16 }); - expect(result.uptimePercent).toBeCloseTo((10 / 12) * 100); - }); + it('resets firing progress on reload without removing an active buff', () => { + const result = evaluateConditionalProgram( + program( + { + action: 'continuous_firing_duration', + params: { seconds: 2 }, + }, + { + lifecycle: { + cooldownSeconds: 0, + durationSeconds: 10, + maxStacks: 4, + stacksPerActivation: 1, + }, + }, + ), + [ + { kind: 'firing_started', at: 0 }, + shot(0.5, 'alpha'), + { kind: 'reload_started', at: 3 }, + { kind: 'reload_completed', at: 4 }, + { kind: 'firing_started', at: 4 }, + { kind: 'firing_stopped', at: 7 }, + ], + { mode: 'encounter', encounterSeconds: 12 }, + ); + expect(result.activations.map((activation) => activation.at)).toEqual([ + 2, 6, + ]); + expect(result.activations[1]).toMatchObject({ + stacksBefore: 1, + stacksAfter: 2, + expiresAt: 16, + }); + expect(result.uptimePercent).toBeCloseTo((10 / 12) * 100); + }); - it('activates continuous firing only once in each uninterrupted segment', () => { - const result = evaluateConditionalProgram(program({ - action: 'continuous_firing_duration', - params: { seconds: 2 } - }), [ - { kind: 'firing_started', at: 0 }, - { kind: 'firing_stopped', at: 7 } - ], { mode: 'encounter', encounterSeconds: 10 }); - expect(result.activations.map((activation) => activation.at)).toEqual([2]); - }); + it('activates continuous firing only once in each uninterrupted segment', () => { + const result = evaluateConditionalProgram( + program({ + action: 'continuous_firing_duration', + params: { seconds: 2 }, + }), + [ + { kind: 'firing_started', at: 0 }, + { kind: 'firing_stopped', at: 7 }, + ], + { mode: 'encounter', encounterSeconds: 10 }, + ); + expect(result.activations.map((activation) => activation.at)).toEqual([2]); + }); - it('uses only the strongest matching health band', () => { - const result = evaluateConditionalProgram(program({ - action: 'health_threshold_bands', - params: { - bands: [ - { thresholdPercent: 50, outcomeKey: 'low' }, - { thresholdPercent: 20, outcomeKey: 'critical' } - ] - } - }, { - lifecycle: { cooldownSeconds: 0, durationSeconds: 0, maxStacks: 1, stacksPerActivation: 1 }, - outcomes: [ - { ...modifier, id: 'low', outcomeKey: 'low', value: 5 }, - { ...modifier, id: 'critical', outcomeKey: 'critical', value: 10 } - ] - }), [], { - mode: 'encounter', - encounterSeconds: 20, - healthPercent: 15 - }); - expect(result.selectedOutcomeKey).toBe('critical'); - expect(result.intervals).toEqual([{ start: 0, end: 20, stacks: 1, outcomeKey: 'critical' }]); - }); + it('uses only the strongest matching health band', () => { + const result = evaluateConditionalProgram( + program( + { + action: 'health_threshold_bands', + params: { + bands: [ + { thresholdPercent: 50, outcomeKey: 'low' }, + { thresholdPercent: 20, outcomeKey: 'critical' }, + ], + }, + }, + { + lifecycle: { + cooldownSeconds: 0, + durationSeconds: 0, + maxStacks: 1, + stacksPerActivation: 1, + }, + outcomes: [ + { ...modifier, id: 'low', outcomeKey: 'low', value: 5 }, + { ...modifier, id: 'critical', outcomeKey: 'critical', value: 10 }, + ], + }, + ), + [], + { + mode: 'encounter', + encounterSeconds: 20, + healthPercent: 15, + }, + ); + expect(result.selectedOutcomeKey).toBe('critical'); + expect(result.intervals).toEqual([ + { start: 0, end: 20, stacks: 1, outcomeKey: 'critical' }, + ]); + }); - it('allows a direct proc-rate override and reports per-stack instant outcomes', () => { - const result = evaluateConditionalProgram(program({ - action: 'weapon_kill', - params: {} - }, { - lifecycle: { cooldownSeconds: 0, durationSeconds: 20, maxStacks: 3, stacksPerActivation: 1 }, - outcomes: [{ - id: 'blast', - scope: 'target', - application: 'direct_damage', - basis: 'weapon_base_percent', - value: 25 - }] - }), [], { - mode: 'encounter', - encounterSeconds: 30, - directProcsPerMinute: 6 - }); - expect(result.activations.map((activation) => activation.at)).toEqual([10, 20, 30]); - expect(result.outcomeEvents.map((event) => event.value)).toEqual([25, 50, 75]); - }); + it('allows a direct proc-rate override and reports per-stack instant outcomes', () => { + const result = evaluateConditionalProgram( + program( + { + action: 'weapon_kill', + params: {}, + }, + { + lifecycle: { + cooldownSeconds: 0, + durationSeconds: 20, + maxStacks: 3, + stacksPerActivation: 1, + }, + outcomes: [ + { + id: 'blast', + scope: 'target', + application: 'direct_damage', + basis: 'weapon_base_percent', + value: 25, + }, + ], + }, + ), + [], + { + mode: 'encounter', + encounterSeconds: 30, + directProcsPerMinute: 6, + }, + ); + expect(result.activations.map((activation) => activation.at)).toEqual([ + 10, 20, 30, + ]); + expect(result.outcomeEvents.map((event) => event.value)).toEqual([ + 25, 50, 75, + ]); + }); - it('uses real trigger events when the whole-program activation override is zero', () => { - const result = evaluateConditionalProgram(program({ - action: 'weapon_kill', - params: {} - }), [ - { kind: 'enemy_killed', at: 4 } - ], { - mode: 'encounter', - encounterSeconds: 30, - directProcsPerMinute: 0 - }); - expect(result.activations.map((activation) => activation.at)).toEqual([4]); - }); + it('uses real trigger events when the whole-program activation override is zero', () => { + const result = evaluateConditionalProgram( + program({ + action: 'weapon_kill', + params: {}, + }), + [{ kind: 'enemy_killed', at: 4 }], + { + mode: 'encounter', + encounterSeconds: 30, + directProcsPerMinute: 0, + }, + ); + expect(result.activations.map((activation) => activation.at)).toEqual([4]); + }); - it('models headshot kills from enemy-killed timeline events', () => { - const result = evaluateConditionalProgram(program({ - action: 'headshot_kill', - params: {} - }), [ - { kind: 'enemy_killed', at: 2, headshot: false }, - { kind: 'enemy_killed', at: 7, headshot: true } - ], { mode: 'encounter', encounterSeconds: 15 }); - expect(result.activations.map((activation) => activation.at)).toEqual([7]); - }); + it('models headshot kills from enemy-killed timeline events', () => { + const result = evaluateConditionalProgram( + program({ + action: 'headshot_kill', + params: {}, + }), + [ + { kind: 'enemy_killed', at: 2, headshot: false }, + { kind: 'enemy_killed', at: 7, headshot: true }, + ], + { mode: 'encounter', encounterSeconds: 15 }, + ); + expect(result.activations.map((activation) => activation.at)).toEqual([7]); + }); - it('activates on successful target hits while the target is within the configured distance', () => { - const inRange = evaluateConditionalProgram(program({ - action: 'target_hit_within_distance', - params: { meters: 20 } - }), [ - shot(1, 'alpha', false), - shot(2, null), - shot(3, 'alpha'), - shot(4, 'bravo') - ], { - mode: 'encounter', - encounterSeconds: 10, - distanceMeters: 20 - }); - expect(inRange.activations.map((activation) => activation.at)).toEqual([3, 4]); + it('activates on successful target hits while the target is within the configured distance', () => { + const inRange = evaluateConditionalProgram( + program({ + action: 'target_hit_within_distance', + params: { meters: 20 }, + }), + [ + shot(1, 'alpha', false), + shot(2, null), + shot(3, 'alpha'), + shot(4, 'bravo'), + ], + { + mode: 'encounter', + encounterSeconds: 10, + distanceMeters: 20, + }, + ); + expect(inRange.activations.map((activation) => activation.at)).toEqual([ + 3, 4, + ]); - const outOfRange = evaluateConditionalProgram(program({ - action: 'target_hit_within_distance', - params: { meters: 20 } - }), [shot(3, 'alpha')], { - mode: 'encounter', - encounterSeconds: 10, - distanceMeters: 21 - }); - expect(outOfRange.activationCount).toBe(0); - }); + const outOfRange = evaluateConditionalProgram( + program({ + action: 'target_hit_within_distance', + params: { meters: 20 }, + }), + [shot(3, 'alpha')], + { + mode: 'encounter', + encounterSeconds: 10, + distanceMeters: 21, + }, + ); + expect(outOfRange.activationCount).toBe(0); + }); - it('requires a target-distance assumption for distance-limited hit events', () => { - const result = evaluateConditionalProgram(program({ - action: 'target_hit_within_distance', - params: { meters: 20 } - }), [shot(3, 'alpha')], { - mode: 'encounter', - encounterSeconds: 10 - }); - expect(result.activationCount).toBe(0); - }); + it('requires a target-distance assumption for distance-limited hit events', () => { + const result = evaluateConditionalProgram( + program({ + action: 'target_hit_within_distance', + params: { meters: 20 }, + }), + [shot(3, 'alpha')], + { + mode: 'encounter', + encounterSeconds: 10, + }, + ); + expect(result.activationCount).toBe(0); + }); - it('keeps target proximity as a persistent condition rather than a hit event', () => { - const result = evaluateConditionalProgram(program({ - action: 'within_distance', - params: { meters: 20 } - }), [], { - mode: 'encounter', - encounterSeconds: 10, - distanceMeters: 20 - }); - expect(result.activationCount).toBe(1); - expect(result.intervals).toEqual([{ start: 0, end: 10, stacks: 10, outcomeKey: null }]); - }); + it('keeps target proximity as a persistent condition rather than a hit event', () => { + const result = evaluateConditionalProgram( + program({ + action: 'within_distance', + params: { meters: 20 }, + }), + [], + { + mode: 'encounter', + encounterSeconds: 10, + distanceMeters: 20, + }, + ); + expect(result.activationCount).toBe(1); + expect(result.intervals).toEqual([ + { start: 0, end: 10, stacks: 10, outcomeKey: null }, + ]); + }); - it('keeps while-firing effects active only inside firing segments', () => { - const result = evaluateConditionalProgram(program({ - action: 'while_firing', - params: {} - }), [ - { kind: 'firing_started', at: 1 }, - { kind: 'firing_stopped', at: 3 }, - { kind: 'firing_started', at: 5 }, - { kind: 'reload_started', at: 8 } - ], { mode: 'encounter', encounterSeconds: 10 }); - expect(result.intervals).toEqual([ - { start: 1, end: 3, stacks: 10, outcomeKey: null }, - { start: 5, end: 8, stacks: 10, outcomeKey: null } - ]); - expect(result.uptimePercent).toBe(50); - }); + it('keeps while-firing effects active only inside firing segments', () => { + const result = evaluateConditionalProgram( + program({ + action: 'while_firing', + params: {}, + }), + [ + { kind: 'firing_started', at: 1 }, + { kind: 'firing_stopped', at: 3 }, + { kind: 'firing_started', at: 5 }, + { kind: 'reload_started', at: 8 }, + ], + { mode: 'encounter', encounterSeconds: 10 }, + ); + expect(result.intervals).toEqual([ + { start: 1, end: 3, stacks: 10, outcomeKey: null }, + { start: 5, end: 8, stacks: 10, outcomeKey: null }, + ]); + expect(result.uptimePercent).toBe(50); + }); }); diff --git a/src/lib/calculations/conditional.ts b/src/lib/calculations/conditional.ts index f042661..ccb1f58 100644 --- a/src/lib/calculations/conditional.ts +++ b/src/lib/calculations/conditional.ts @@ -1,976 +1,1164 @@ export const TRIGGER_ACTION_KEYS = [ - 'same_target_hits_within_window', - 'distinct_enemies_within_window', - 'consecutive_same_target_hits', - 'continuous_firing_duration', - 'while_firing', - 'health_threshold_bands', - 'headshot', - 'body_shot', - 'headshot_kill', - 'weapon_kill', - 'target_hit_within_distance', - 'damage_taken', - 'weapon_swap', - 'reload', - 'within_distance', - 'extra_health_active' + 'same_target_hits_within_window', + 'distinct_enemies_within_window', + 'consecutive_same_target_hits', + 'continuous_firing_duration', + 'while_firing', + 'health_threshold_bands', + 'headshot', + 'body_shot', + 'headshot_kill', + 'weapon_kill', + 'target_hit_within_distance', + 'damage_taken', + 'weapon_swap', + 'reload', + 'within_distance', + 'extra_health_active', ] as const; export type TriggerActionKey = (typeof TRIGGER_ACTION_KEYS)[number]; export type SameTargetHitsParams = { - hitsRequired: number; - windowSeconds: number; + hitsRequired: number; + windowSeconds: number; }; export type DistinctEnemiesParams = { - enemiesRequired: number; - windowSeconds: number; + enemiesRequired: number; + windowSeconds: number; }; export type ConsecutiveHitsParams = { - hitsRequired: number; + hitsRequired: number; }; export type ContinuousFiringParams = { - seconds: number; + seconds: number; }; export type HealthThresholdBand = { - thresholdPercent: number; - outcomeKey: string; + thresholdPercent: number; + outcomeKey: string; }; export type HealthThresholdParams = { - bands: HealthThresholdBand[]; + bands: HealthThresholdBand[]; }; export type DistanceParams = { - meters: number; + meters: number; }; export type EmptyTriggerParams = Record; export type TriggerParamsByAction = { - same_target_hits_within_window: SameTargetHitsParams; - distinct_enemies_within_window: DistinctEnemiesParams; - consecutive_same_target_hits: ConsecutiveHitsParams; - continuous_firing_duration: ContinuousFiringParams; - while_firing: EmptyTriggerParams; - health_threshold_bands: HealthThresholdParams; - headshot: EmptyTriggerParams; - body_shot: EmptyTriggerParams; - headshot_kill: EmptyTriggerParams; - weapon_kill: EmptyTriggerParams; - target_hit_within_distance: DistanceParams; - damage_taken: EmptyTriggerParams; - weapon_swap: EmptyTriggerParams; - reload: EmptyTriggerParams; - within_distance: DistanceParams; - extra_health_active: EmptyTriggerParams; + same_target_hits_within_window: SameTargetHitsParams; + distinct_enemies_within_window: DistinctEnemiesParams; + consecutive_same_target_hits: ConsecutiveHitsParams; + continuous_firing_duration: ContinuousFiringParams; + while_firing: EmptyTriggerParams; + health_threshold_bands: HealthThresholdParams; + headshot: EmptyTriggerParams; + body_shot: EmptyTriggerParams; + headshot_kill: EmptyTriggerParams; + weapon_kill: EmptyTriggerParams; + target_hit_within_distance: DistanceParams; + damage_taken: EmptyTriggerParams; + weapon_swap: EmptyTriggerParams; + reload: EmptyTriggerParams; + within_distance: DistanceParams; + extra_health_active: EmptyTriggerParams; }; export type TriggerConfiguration = { - [K in TriggerActionKey]: { - action: K; - params: TriggerParamsByAction[K]; - } + [K in TriggerActionKey]: { + action: K; + params: TriggerParamsByAction[K]; + }; }[TriggerActionKey]; export type EffectScope = - | 'self' - | 'equipped_weapon' - | 'target' - | 'allies' - | 'self_and_allies' - | 'assigned_ability' - | 'all_skills'; + | 'self' + | 'equipped_weapon' + | 'target' + | 'allies' + | 'self_and_allies' + | 'assigned_ability' + | 'all_skills'; export type ModifierOutcome = { - id: string; - outcomeKey?: string; - scope: EffectScope; - application: 'active_modifier'; - attributeId: string; - value: number; + id: string; + outcomeKey?: string; + scope: EffectScope; + application: 'active_modifier'; + attributeId: string; + value: number; }; export type DirectDamageOutcome = { - id: string; - outcomeKey?: string; - scope: EffectScope; - application: 'direct_damage'; - basis: 'flat' | 'weapon_base_percent'; - value: number; + id: string; + outcomeKey?: string; + scope: EffectScope; + application: 'direct_damage'; + basis: 'flat' | 'weapon_base_percent'; + value: number; }; export type RestorationOutcome = { - id: string; - outcomeKey?: string; - scope: EffectScope; - application: 'restore_health' | 'restore_armor' | 'restore_ammo'; - value: number; + id: string; + outcomeKey?: string; + scope: EffectScope; + application: 'restore_health' | 'restore_armor' | 'restore_ammo'; + value: number; }; -export type ConditionalOutcome = ModifierOutcome | DirectDamageOutcome | RestorationOutcome; +export type ConditionalOutcome = + ModifierOutcome | DirectDamageOutcome | RestorationOutcome; export type ConditionalLifecycle = { - cooldownSeconds: number; - durationSeconds: number; - maxStacks: number; - stacksPerActivation: number; + cooldownSeconds: number; + durationSeconds: number; + maxStacks: number; + stacksPerActivation: number; }; export type ConditionalProgram = { - id: string; - trigger: TriggerConfiguration; - lifecycle: ConditionalLifecycle; - outcomes: ConditionalOutcome[]; + id: string; + trigger: TriggerConfiguration; + lifecycle: ConditionalLifecycle; + outcomes: ConditionalOutcome[]; }; export type ShotEvent = { - kind: 'shot'; - at: number; - targetId?: string | null; - hit: boolean; - headshot?: boolean; + kind: 'shot'; + at: number; + targetId?: string | null; + hit: boolean; + headshot?: boolean; }; export type EnemyKilledEvent = { - kind: 'enemy_killed'; - at: number; - targetId?: string | null; - headshot?: boolean; + kind: 'enemy_killed'; + at: number; + targetId?: string | null; + headshot?: boolean; }; export type ConditionalEvent = - | ShotEvent - | EnemyKilledEvent - | { kind: 'firing_started'; at: number } - | { kind: 'firing_stopped'; at: number } - | { kind: 'reload_started'; at: number } - | { kind: 'reload_completed'; at: number } - | { kind: 'damage_taken'; at: number } - | { kind: 'weapon_swapped'; at: number }; + | ShotEvent + | EnemyKilledEvent + | { kind: 'firing_started'; at: number } + | { kind: 'firing_stopped'; at: number } + | { kind: 'reload_started'; at: number } + | { kind: 'reload_completed'; at: number } + | { kind: 'damage_taken'; at: number } + | { kind: 'weapon_swapped'; at: number }; export type ConditionalMode = 'off' | 'peak' | 'encounter'; export type ConditionalAssumptions = { - mode: ConditionalMode; - encounterSeconds: number; - healthPercent?: number; - distanceMeters?: number; - extraHealthActive?: boolean; - directProcsPerMinute?: number; + mode: ConditionalMode; + encounterSeconds: number; + healthPercent?: number; + distanceMeters?: number; + extraHealthActive?: boolean; + directProcsPerMinute?: number; }; export type ValidationResult = - | { ok: true; value: T } - | { ok: false; errors: string[] }; + { ok: true; value: T } | { ok: false; errors: string[] }; export type TriggerActionDefinition = { - key: TriggerActionKey; - label: string; - validate: (params: unknown) => ValidationResult; - summarize: (params: TriggerParamsByAction[TriggerActionKey]) => string; + key: TriggerActionKey; + label: string; + validate: ( + params: unknown, + ) => ValidationResult; + summarize: (params: TriggerParamsByAction[TriggerActionKey]) => string; }; export type ConditionalActivation = { - at: number; - effectiveAfter: number; - stacksBefore: number; - stacksAfter: number; - expiresAt: number; - cooldownUntil: number; - outcomeKey: string | null; + at: number; + effectiveAfter: number; + stacksBefore: number; + stacksAfter: number; + expiresAt: number; + cooldownUntil: number; + outcomeKey: string | null; }; export type ConditionalStateInterval = { - start: number; - end: number; - stacks: number; - outcomeKey: string | null; + start: number; + end: number; + stacks: number; + outcomeKey: string | null; }; export type ConditionalOutcomeEvent = { - at: number; - effectiveAfter: number; - outcomeId: string; - application: Exclude; - scope: EffectScope; - value: number; - basis: DirectDamageOutcome['basis'] | null; - stacks: number; + at: number; + effectiveAfter: number; + outcomeId: string; + application: Exclude; + scope: EffectScope; + value: number; + basis: DirectDamageOutcome['basis'] | null; + stacks: number; }; export type ConditionalEvaluation = { - status: 'OK' | 'INVALID'; - errors: string[]; - programId: string; - mode: ConditionalMode; - activations: ConditionalActivation[]; - intervals: ConditionalStateInterval[]; - outcomeEvents: ConditionalOutcomeEvent[]; - activationCount: number; - maxStacksReached: number; - averageStacks: number; - uptimePercent: number; - selectedOutcomeKey: string | null; + status: 'OK' | 'INVALID'; + errors: string[]; + programId: string; + mode: ConditionalMode; + activations: ConditionalActivation[]; + intervals: ConditionalStateInterval[]; + outcomeEvents: ConditionalOutcomeEvent[]; + activationCount: number; + maxStacksReached: number; + averageStacks: number; + uptimePercent: number; + selectedOutcomeKey: string | null; }; const EPSILON = 0.000_001; -const finiteNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value); +const finiteNumber = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value); const isObject = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value); -const clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value)); + typeof value === 'object' && value !== null && !Array.isArray(value); +const clamp = (value: number, low: number, high: number) => + Math.min(high, Math.max(low, value)); const plural = (value: number, singular: string, pluralForm = `${singular}s`) => - `${formatNumber(value)} ${value === 1 ? singular : pluralForm}`; -const formatNumber = (value: number) => Number.isInteger(value) ? String(value) : String(Number(value.toFixed(2))); + `${formatNumber(value)} ${value === 1 ? singular : pluralForm}`; +const formatNumber = (value: number) => + Number.isInteger(value) ? String(value) : String(Number(value.toFixed(2))); function positiveNumber(value: unknown, name: string, errors: string[]) { - if (!finiteNumber(value) || value <= 0) errors.push(`${name} must be greater than 0.`); + if (!finiteNumber(value) || value <= 0) + errors.push(`${name} must be greater than 0.`); } function positiveInteger(value: unknown, name: string, errors: string[]) { - if (!Number.isInteger(value) || Number(value) <= 0) errors.push(`${name} must be a positive integer.`); + if (!Number.isInteger(value) || Number(value) <= 0) + errors.push(`${name} must be a positive integer.`); } function validateEmpty(params: unknown): ValidationResult { - if (params === undefined || (isObject(params) && Object.keys(params).length === 0)) { - return { ok: true, value: {} }; - } - return { ok: false, errors: ['This trigger does not accept parameters.'] }; + if ( + params === undefined || + (isObject(params) && Object.keys(params).length === 0) + ) { + return { ok: true, value: {} }; + } + return { ok: false, errors: ['This trigger does not accept parameters.'] }; } -function validateSameTarget(params: unknown): ValidationResult { - const errors: string[] = []; - if (!isObject(params)) return { ok: false, errors: ['Trigger parameters must be an object.'] }; - positiveInteger(params.hitsRequired, 'Hits required', errors); - positiveNumber(params.windowSeconds, 'Window seconds', errors); - return errors.length - ? { ok: false, errors } - : { ok: true, value: { hitsRequired: Number(params.hitsRequired), windowSeconds: Number(params.windowSeconds) } }; +function validateSameTarget( + params: unknown, +): ValidationResult { + const errors: string[] = []; + if (!isObject(params)) + return { ok: false, errors: ['Trigger parameters must be an object.'] }; + positiveInteger(params.hitsRequired, 'Hits required', errors); + positiveNumber(params.windowSeconds, 'Window seconds', errors); + return errors.length + ? { ok: false, errors } + : { + ok: true, + value: { + hitsRequired: Number(params.hitsRequired), + windowSeconds: Number(params.windowSeconds), + }, + }; } -function validateDistinctEnemies(params: unknown): ValidationResult { - const errors: string[] = []; - if (!isObject(params)) return { ok: false, errors: ['Trigger parameters must be an object.'] }; - positiveInteger(params.enemiesRequired, 'Enemies required', errors); - positiveNumber(params.windowSeconds, 'Window seconds', errors); - return errors.length - ? { ok: false, errors } - : { ok: true, value: { enemiesRequired: Number(params.enemiesRequired), windowSeconds: Number(params.windowSeconds) } }; +function validateDistinctEnemies( + params: unknown, +): ValidationResult { + const errors: string[] = []; + if (!isObject(params)) + return { ok: false, errors: ['Trigger parameters must be an object.'] }; + positiveInteger(params.enemiesRequired, 'Enemies required', errors); + positiveNumber(params.windowSeconds, 'Window seconds', errors); + return errors.length + ? { ok: false, errors } + : { + ok: true, + value: { + enemiesRequired: Number(params.enemiesRequired), + windowSeconds: Number(params.windowSeconds), + }, + }; } -function validateConsecutive(params: unknown): ValidationResult { - const errors: string[] = []; - if (!isObject(params)) return { ok: false, errors: ['Trigger parameters must be an object.'] }; - positiveInteger(params.hitsRequired, 'Hits required', errors); - return errors.length - ? { ok: false, errors } - : { ok: true, value: { hitsRequired: Number(params.hitsRequired) } }; +function validateConsecutive( + params: unknown, +): ValidationResult { + const errors: string[] = []; + if (!isObject(params)) + return { ok: false, errors: ['Trigger parameters must be an object.'] }; + positiveInteger(params.hitsRequired, 'Hits required', errors); + return errors.length + ? { ok: false, errors } + : { ok: true, value: { hitsRequired: Number(params.hitsRequired) } }; } -function validateContinuousFiring(params: unknown): ValidationResult { - const errors: string[] = []; - if (!isObject(params)) return { ok: false, errors: ['Trigger parameters must be an object.'] }; - positiveNumber(params.seconds, 'Firing seconds', errors); - return errors.length - ? { ok: false, errors } - : { ok: true, value: { seconds: Number(params.seconds) } }; +function validateContinuousFiring( + params: unknown, +): ValidationResult { + const errors: string[] = []; + if (!isObject(params)) + return { ok: false, errors: ['Trigger parameters must be an object.'] }; + positiveNumber(params.seconds, 'Firing seconds', errors); + return errors.length + ? { ok: false, errors } + : { ok: true, value: { seconds: Number(params.seconds) } }; } -function validateHealthBands(params: unknown): ValidationResult { - const errors: string[] = []; - if (!isObject(params) || !Array.isArray(params.bands)) { - return { ok: false, errors: ['Health bands must be an array.'] }; - } - if (params.bands.length < 1 || params.bands.length > 5) { - errors.push('Health threshold triggers require between 1 and 5 bands.'); - } - const bands: HealthThresholdBand[] = []; - const thresholds = new Set(); - const outcomeKeys = new Set(); - for (const [index, candidate] of params.bands.entries()) { - if (!isObject(candidate)) { - errors.push(`Health band ${index + 1} must be an object.`); - continue; - } - const threshold = candidate.thresholdPercent; - const outcomeKey = typeof candidate.outcomeKey === 'string' ? candidate.outcomeKey.trim() : ''; - if (!finiteNumber(threshold) || threshold < 0 || threshold > 100) { - errors.push(`Health band ${index + 1} threshold must be between 0 and 100.`); - } - if (!outcomeKey) errors.push(`Health band ${index + 1} requires an outcome key.`); - if (finiteNumber(threshold) && thresholds.has(threshold)) { - errors.push(`Health threshold ${formatNumber(threshold)}% is duplicated.`); - } - if (outcomeKey && outcomeKeys.has(outcomeKey)) errors.push(`Outcome key "${outcomeKey}" is duplicated.`); - if (finiteNumber(threshold)) thresholds.add(threshold); - if (outcomeKey) outcomeKeys.add(outcomeKey); - if (finiteNumber(threshold) && outcomeKey) bands.push({ thresholdPercent: threshold, outcomeKey }); - } - return errors.length - ? { ok: false, errors } - : { ok: true, value: { bands: bands.sort((a, b) => a.thresholdPercent - b.thresholdPercent) } }; +function validateHealthBands( + params: unknown, +): ValidationResult { + const errors: string[] = []; + if (!isObject(params) || !Array.isArray(params.bands)) { + return { ok: false, errors: ['Health bands must be an array.'] }; + } + if (params.bands.length < 1 || params.bands.length > 5) { + errors.push('Health threshold triggers require between 1 and 5 bands.'); + } + const bands: HealthThresholdBand[] = []; + const thresholds = new Set(); + const outcomeKeys = new Set(); + for (const [index, candidate] of params.bands.entries()) { + if (!isObject(candidate)) { + errors.push(`Health band ${index + 1} must be an object.`); + continue; + } + const threshold = candidate.thresholdPercent; + const outcomeKey = + typeof candidate.outcomeKey === 'string' + ? candidate.outcomeKey.trim() + : ''; + if (!finiteNumber(threshold) || threshold < 0 || threshold > 100) { + errors.push( + `Health band ${index + 1} threshold must be between 0 and 100.`, + ); + } + if (!outcomeKey) + errors.push(`Health band ${index + 1} requires an outcome key.`); + if (finiteNumber(threshold) && thresholds.has(threshold)) { + errors.push( + `Health threshold ${formatNumber(threshold)}% is duplicated.`, + ); + } + if (outcomeKey && outcomeKeys.has(outcomeKey)) + errors.push(`Outcome key "${outcomeKey}" is duplicated.`); + if (finiteNumber(threshold)) thresholds.add(threshold); + if (outcomeKey) outcomeKeys.add(outcomeKey); + if (finiteNumber(threshold) && outcomeKey) + bands.push({ thresholdPercent: threshold, outcomeKey }); + } + return errors.length + ? { ok: false, errors } + : { + ok: true, + value: { + bands: bands.sort((a, b) => a.thresholdPercent - b.thresholdPercent), + }, + }; } function validateDistance(params: unknown): ValidationResult { - const errors: string[] = []; - if (!isObject(params)) return { ok: false, errors: ['Trigger parameters must be an object.'] }; - positiveNumber(params.meters, 'Distance', errors); - return errors.length - ? { ok: false, errors } - : { ok: true, value: { meters: Number(params.meters) } }; + const errors: string[] = []; + if (!isObject(params)) + return { ok: false, errors: ['Trigger parameters must be an object.'] }; + positiveNumber(params.meters, 'Distance', errors); + return errors.length + ? { ok: false, errors } + : { ok: true, value: { meters: Number(params.meters) } }; } const emptySummary = () => ''; -export const TRIGGER_ACTION_REGISTRY: Readonly> = { - same_target_hits_within_window: { - key: 'same_target_hits_within_window', - label: 'Same-target hits within a window', - validate: validateSameTarget, - summarize: (params) => { - const value = params as SameTargetHitsParams; - return `Hit the same enemy ${plural(value.hitsRequired, 'time')} within ${plural(value.windowSeconds, 'second')}.`; - } - }, - distinct_enemies_within_window: { - key: 'distinct_enemies_within_window', - label: 'Distinct enemies hit within a window', - validate: validateDistinctEnemies, - summarize: (params) => { - const value = params as DistinctEnemiesParams; - return `Hit ${plural(value.enemiesRequired, 'distinct enemy', 'distinct enemies')} within ${plural(value.windowSeconds, 'second')}.`; - } - }, - consecutive_same_target_hits: { - key: 'consecutive_same_target_hits', - label: 'Consecutive same-target hits', - validate: validateConsecutive, - summarize: (params) => { - const value = params as ConsecutiveHitsParams; - return `Hit the same enemy ${plural(value.hitsRequired, 'consecutive time')}.`; - } - }, - continuous_firing_duration: { - key: 'continuous_firing_duration', - label: 'Continuous firing duration', - validate: validateContinuousFiring, - summarize: (params) => { - const value = params as ContinuousFiringParams; - return `Fire continuously for ${plural(value.seconds, 'second')}.`; - } - }, - while_firing: { - key: 'while_firing', - label: 'While firing', - validate: validateEmpty, - summarize: () => 'While firing.' - }, - health_threshold_bands: { - key: 'health_threshold_bands', - label: 'Health threshold bands', - validate: validateHealthBands, - summarize: (params) => { - const value = params as HealthThresholdParams; - const thresholds = [...value.bands] - .sort((a, b) => b.thresholdPercent - a.thresholdPercent) - .map((band) => `${formatNumber(band.thresholdPercent)}% (${band.outcomeKey})`); - return `Use the strongest matching effect while health is at or below ${thresholds.join(', ')}.`; - } - }, - headshot: { - key: 'headshot', - label: 'Headshot', - validate: validateEmpty, - summarize: () => 'Land a headshot.' - }, - body_shot: { - key: 'body_shot', - label: 'Body shot', - validate: validateEmpty, - summarize: () => 'Land a non-headshot weapon hit.' - }, - headshot_kill: { - key: 'headshot_kill', - label: 'Headshot kill', - validate: validateEmpty, - summarize: () => 'Kill an enemy with a headshot.' - }, - weapon_kill: { - key: 'weapon_kill', - label: 'Weapon kill', - validate: validateEmpty, - summarize: () => 'Kill an enemy with a weapon.' - }, - target_hit_within_distance: { - key: 'target_hit_within_distance', - label: 'Target hit within distance', - validate: validateDistance, - summarize: (params) => { - const value = params as DistanceParams; - return `Hit a target within ${plural(value.meters, 'meter')}.`; - } - }, - damage_taken: { - key: 'damage_taken', - label: 'Damage taken', - validate: validateEmpty, - summarize: () => 'Take damage.' - }, - weapon_swap: { - key: 'weapon_swap', - label: 'Weapon swap', - validate: validateEmpty, - summarize: () => 'Swap weapons.' - }, - reload: { - key: 'reload', - label: 'Reload', - validate: validateEmpty, - summarize: () => 'Start reloading.' - }, - within_distance: { - key: 'within_distance', - label: 'Target within distance', - validate: validateDistance, - summarize: (params) => { - const value = params as DistanceParams; - return `While the target is within ${plural(value.meters, 'meter')}.`; - } - }, - extra_health_active: { - key: 'extra_health_active', - label: 'Extra health active', - validate: validateEmpty, - summarize: () => 'While extra health is active.' - } +export const TRIGGER_ACTION_REGISTRY: Readonly< + Record +> = { + same_target_hits_within_window: { + key: 'same_target_hits_within_window', + label: 'Same-target hits within a window', + validate: validateSameTarget, + summarize: (params) => { + const value = params as SameTargetHitsParams; + return `Hit the same enemy ${plural(value.hitsRequired, 'time')} within ${plural(value.windowSeconds, 'second')}.`; + }, + }, + distinct_enemies_within_window: { + key: 'distinct_enemies_within_window', + label: 'Distinct enemies hit within a window', + validate: validateDistinctEnemies, + summarize: (params) => { + const value = params as DistinctEnemiesParams; + return `Hit ${plural(value.enemiesRequired, 'distinct enemy', 'distinct enemies')} within ${plural(value.windowSeconds, 'second')}.`; + }, + }, + consecutive_same_target_hits: { + key: 'consecutive_same_target_hits', + label: 'Consecutive same-target hits', + validate: validateConsecutive, + summarize: (params) => { + const value = params as ConsecutiveHitsParams; + return `Hit the same enemy ${plural(value.hitsRequired, 'consecutive time')}.`; + }, + }, + continuous_firing_duration: { + key: 'continuous_firing_duration', + label: 'Continuous firing duration', + validate: validateContinuousFiring, + summarize: (params) => { + const value = params as ContinuousFiringParams; + return `Fire continuously for ${plural(value.seconds, 'second')}.`; + }, + }, + while_firing: { + key: 'while_firing', + label: 'While firing', + validate: validateEmpty, + summarize: () => 'While firing.', + }, + health_threshold_bands: { + key: 'health_threshold_bands', + label: 'Health threshold bands', + validate: validateHealthBands, + summarize: (params) => { + const value = params as HealthThresholdParams; + const thresholds = [...value.bands] + .sort((a, b) => b.thresholdPercent - a.thresholdPercent) + .map( + (band) => + `${formatNumber(band.thresholdPercent)}% (${band.outcomeKey})`, + ); + return `Use the strongest matching effect while health is at or below ${thresholds.join(', ')}.`; + }, + }, + headshot: { + key: 'headshot', + label: 'Headshot', + validate: validateEmpty, + summarize: () => 'Land a headshot.', + }, + body_shot: { + key: 'body_shot', + label: 'Body shot', + validate: validateEmpty, + summarize: () => 'Land a non-headshot weapon hit.', + }, + headshot_kill: { + key: 'headshot_kill', + label: 'Headshot kill', + validate: validateEmpty, + summarize: () => 'Kill an enemy with a headshot.', + }, + weapon_kill: { + key: 'weapon_kill', + label: 'Weapon kill', + validate: validateEmpty, + summarize: () => 'Kill an enemy with a weapon.', + }, + target_hit_within_distance: { + key: 'target_hit_within_distance', + label: 'Target hit within distance', + validate: validateDistance, + summarize: (params) => { + const value = params as DistanceParams; + return `Hit a target within ${plural(value.meters, 'meter')}.`; + }, + }, + damage_taken: { + key: 'damage_taken', + label: 'Damage taken', + validate: validateEmpty, + summarize: () => 'Take damage.', + }, + weapon_swap: { + key: 'weapon_swap', + label: 'Weapon swap', + validate: validateEmpty, + summarize: () => 'Swap weapons.', + }, + reload: { + key: 'reload', + label: 'Reload', + validate: validateEmpty, + summarize: () => 'Start reloading.', + }, + within_distance: { + key: 'within_distance', + label: 'Target within distance', + validate: validateDistance, + summarize: (params) => { + const value = params as DistanceParams; + return `While the target is within ${plural(value.meters, 'meter')}.`; + }, + }, + extra_health_active: { + key: 'extra_health_active', + label: 'Extra health active', + validate: validateEmpty, + summarize: () => 'While extra health is active.', + }, }; export function isTriggerActionKey(value: unknown): value is TriggerActionKey { - return typeof value === 'string' && (TRIGGER_ACTION_KEYS as readonly string[]).includes(value); + return ( + typeof value === 'string' && + (TRIGGER_ACTION_KEYS as readonly string[]).includes(value) + ); } export function validateTriggerConfiguration( - action: TriggerActionKey, - params: unknown + action: TriggerActionKey, + params: unknown, ): ValidationResult { - const result = TRIGGER_ACTION_REGISTRY[action].validate(params); - if (!result.ok) return result; - return { ok: true, value: { action, params: result.value } as TriggerConfiguration }; + const result = TRIGGER_ACTION_REGISTRY[action].validate(params); + if (!result.ok) return result; + return { + ok: true, + value: { action, params: result.value } as TriggerConfiguration, + }; } export function summarizeTrigger(configuration: TriggerConfiguration): string { - return TRIGGER_ACTION_REGISTRY[configuration.action].summarize(configuration.params); + return TRIGGER_ACTION_REGISTRY[configuration.action].summarize( + configuration.params, + ); } -export function validateConditionalProgram(program: ConditionalProgram): ValidationResult { - const errors: string[] = []; - if (!program.id.trim()) errors.push('Program ID is required.'); - const trigger = validateTriggerConfiguration(program.trigger.action, program.trigger.params); - if (!trigger.ok) errors.push(...trigger.errors.map((error) => `Trigger: ${error}`)); - const lifecycle = program.lifecycle; - if (!finiteNumber(lifecycle.cooldownSeconds) || lifecycle.cooldownSeconds < 0) { - errors.push('Cooldown seconds must be 0 or greater.'); - } - if (!finiteNumber(lifecycle.durationSeconds) || lifecycle.durationSeconds < 0) { - errors.push('Duration seconds must be 0 or greater.'); - } - positiveInteger(lifecycle.maxStacks, 'Maximum stacks', errors); - positiveInteger(lifecycle.stacksPerActivation, 'Stacks per activation', errors); - if ( - Number.isInteger(lifecycle.maxStacks) - && Number.isInteger(lifecycle.stacksPerActivation) - && lifecycle.stacksPerActivation > lifecycle.maxStacks - ) { - errors.push('Stacks per activation cannot exceed maximum stacks.'); - } - const outcomeIds = new Set(); - const healthOutcomeKeys = program.trigger.action === 'health_threshold_bands' - ? new Set((program.trigger.params as HealthThresholdParams).bands.map((band) => band.outcomeKey)) - : null; - for (const [index, outcome] of program.outcomes.entries()) { - if (!outcome.id.trim()) errors.push(`Outcome ${index + 1} requires an ID.`); - if (outcomeIds.has(outcome.id)) errors.push(`Outcome ID "${outcome.id}" is duplicated.`); - outcomeIds.add(outcome.id); - if (!finiteNumber(outcome.value)) errors.push(`Outcome "${outcome.id}" requires a finite value.`); - if ( - outcome.application !== 'active_modifier' - && finiteNumber(outcome.value) - && outcome.value < 0 - ) { - errors.push(`Instant outcome "${outcome.id}" cannot use a negative value.`); - } - if (outcome.application === 'active_modifier' && !outcome.attributeId.trim()) { - errors.push(`Modifier outcome "${outcome.id}" requires an Attribute ID.`); - } - if (healthOutcomeKeys && (!outcome.outcomeKey || !healthOutcomeKeys.has(outcome.outcomeKey))) { - errors.push(`Outcome "${outcome.id}" must reference a configured health-band outcome key.`); - } - } - if (healthOutcomeKeys) { - const provided = new Set(program.outcomes.map((outcome) => outcome.outcomeKey).filter(Boolean)); - for (const key of healthOutcomeKeys) { - if (!provided.has(key)) errors.push(`Health-band outcome "${key}" has no configured effect.`); - } - } - return errors.length ? { ok: false, errors } : { ok: true, value: program }; +export function validateConditionalProgram( + program: ConditionalProgram, +): ValidationResult { + const errors: string[] = []; + if (!program.id.trim()) errors.push('Program ID is required.'); + const trigger = validateTriggerConfiguration( + program.trigger.action, + program.trigger.params, + ); + if (!trigger.ok) + errors.push(...trigger.errors.map((error) => `Trigger: ${error}`)); + const lifecycle = program.lifecycle; + if ( + !finiteNumber(lifecycle.cooldownSeconds) || + lifecycle.cooldownSeconds < 0 + ) { + errors.push('Cooldown seconds must be 0 or greater.'); + } + if ( + !finiteNumber(lifecycle.durationSeconds) || + lifecycle.durationSeconds < 0 + ) { + errors.push('Duration seconds must be 0 or greater.'); + } + positiveInteger(lifecycle.maxStacks, 'Maximum stacks', errors); + positiveInteger( + lifecycle.stacksPerActivation, + 'Stacks per activation', + errors, + ); + if ( + Number.isInteger(lifecycle.maxStacks) && + Number.isInteger(lifecycle.stacksPerActivation) && + lifecycle.stacksPerActivation > lifecycle.maxStacks + ) { + errors.push('Stacks per activation cannot exceed maximum stacks.'); + } + const outcomeIds = new Set(); + const healthOutcomeKeys = + program.trigger.action === 'health_threshold_bands' + ? new Set( + (program.trigger.params as HealthThresholdParams).bands.map( + (band) => band.outcomeKey, + ), + ) + : null; + for (const [index, outcome] of program.outcomes.entries()) { + if (!outcome.id.trim()) errors.push(`Outcome ${index + 1} requires an ID.`); + if (outcomeIds.has(outcome.id)) + errors.push(`Outcome ID "${outcome.id}" is duplicated.`); + outcomeIds.add(outcome.id); + if (!finiteNumber(outcome.value)) + errors.push(`Outcome "${outcome.id}" requires a finite value.`); + if ( + outcome.application !== 'active_modifier' && + finiteNumber(outcome.value) && + outcome.value < 0 + ) { + errors.push( + `Instant outcome "${outcome.id}" cannot use a negative value.`, + ); + } + if ( + outcome.application === 'active_modifier' && + !outcome.attributeId.trim() + ) { + errors.push(`Modifier outcome "${outcome.id}" requires an Attribute ID.`); + } + if ( + healthOutcomeKeys && + (!outcome.outcomeKey || !healthOutcomeKeys.has(outcome.outcomeKey)) + ) { + errors.push( + `Outcome "${outcome.id}" must reference a configured health-band outcome key.`, + ); + } + } + if (healthOutcomeKeys) { + const provided = new Set( + program.outcomes.map((outcome) => outcome.outcomeKey).filter(Boolean), + ); + for (const key of healthOutcomeKeys) { + if (!provided.has(key)) + errors.push(`Health-band outcome "${key}" has no configured effect.`); + } + } + return errors.length ? { ok: false, errors } : { ok: true, value: program }; } type CandidateActivation = { at: number; outcomeKey: string | null }; type FiringSegment = { start: number; end: number }; function orderedEvents(events: ConditionalEvent[], encounterSeconds: number) { - return events - .map((event, index) => ({ event, index })) - .filter(({ event }) => finiteNumber(event.at) && event.at >= 0 && event.at <= encounterSeconds) - .sort((left, right) => left.event.at - right.event.at || left.index - right.index) - .map(({ event }) => event); + return events + .map((event, index) => ({ event, index })) + .filter( + ({ event }) => + finiteNumber(event.at) && event.at >= 0 && event.at <= encounterSeconds, + ) + .sort( + (left, right) => + left.event.at - right.event.at || left.index - right.index, + ) + .map(({ event }) => event); } -function firingSegments(events: ConditionalEvent[], encounterSeconds: number): FiringSegment[] { - const segments: FiringSegment[] = []; - let startedAt: number | null = null; - for (const event of events) { - if (event.kind === 'firing_started') { - if (startedAt === null) startedAt = event.at; - continue; - } - if (event.kind === 'shot' && startedAt === null) { - startedAt = event.at; - continue; - } - if ((event.kind === 'firing_stopped' || event.kind === 'reload_started') && startedAt !== null) { - segments.push({ start: startedAt, end: event.at }); - startedAt = null; - } - } - if (startedAt !== null) segments.push({ start: startedAt, end: encounterSeconds }); - return segments.filter((segment) => segment.end - segment.start > EPSILON); +function firingSegments( + events: ConditionalEvent[], + encounterSeconds: number, +): FiringSegment[] { + const segments: FiringSegment[] = []; + let startedAt: number | null = null; + for (const event of events) { + if (event.kind === 'firing_started') { + if (startedAt === null) startedAt = event.at; + continue; + } + if (event.kind === 'shot' && startedAt === null) { + startedAt = event.at; + continue; + } + if ( + (event.kind === 'firing_stopped' || event.kind === 'reload_started') && + startedAt !== null + ) { + segments.push({ start: startedAt, end: event.at }); + startedAt = null; + } + } + if (startedAt !== null) + segments.push({ start: startedAt, end: encounterSeconds }); + return segments.filter((segment) => segment.end - segment.start > EPSILON); } function eventCandidates( - trigger: TriggerConfiguration, - events: ConditionalEvent[], - assumptions: ConditionalAssumptions + trigger: TriggerConfiguration, + events: ConditionalEvent[], + assumptions: ConditionalAssumptions, ): CandidateActivation[] { - switch (trigger.action) { - case 'same_target_hits_within_window': { - const params = trigger.params; - const hits = new Map(); - const candidates: CandidateActivation[] = []; - for (const event of events) { - if (event.kind !== 'shot' || !event.hit || !event.targetId) continue; - const recent = (hits.get(event.targetId) ?? []).filter( - (at) => event.at - at <= params.windowSeconds + EPSILON - ); - recent.push(event.at); - if (recent.length >= params.hitsRequired) { - candidates.push({ at: event.at, outcomeKey: null }); - hits.set(event.targetId, []); - } else { - hits.set(event.targetId, recent); - } - } - return candidates; - } - case 'distinct_enemies_within_window': { - const params = trigger.params; - let recent = new Map(); - const candidates: CandidateActivation[] = []; - for (const event of events) { - if (event.kind !== 'shot' || !event.hit || !event.targetId) continue; - recent = new Map( - [...recent].filter(([, at]) => event.at - at <= params.windowSeconds + EPSILON) - ); - recent.set(event.targetId, event.at); - if (recent.size >= params.enemiesRequired) { - candidates.push({ at: event.at, outcomeKey: null }); - recent.clear(); - } - } - return candidates; - } - case 'consecutive_same_target_hits': { - const params = trigger.params; - let targetId: string | null = null; - let count = 0; - const candidates: CandidateActivation[] = []; - for (const event of events) { - if (event.kind !== 'shot') continue; - if (!event.hit || !event.targetId) { - targetId = null; - count = 0; - continue; - } - if (event.targetId !== targetId) { - targetId = event.targetId; - count = 1; - } else { - count += 1; - } - if (count >= params.hitsRequired) { - candidates.push({ at: event.at, outcomeKey: null }); - count = 0; - } - } - return candidates; - } - case 'continuous_firing_duration': { - return firingSegments(events, assumptions.encounterSeconds) - .filter((segment) => segment.end - segment.start + EPSILON >= trigger.params.seconds) - .map((segment) => ({ - at: segment.start + trigger.params.seconds, - outcomeKey: null - })); - } - case 'headshot': - return events - .filter((event): event is ShotEvent => event.kind === 'shot' && event.hit && event.headshot === true) - .map((event) => ({ at: event.at, outcomeKey: null })); - case 'body_shot': - return events - .filter((event): event is ShotEvent => event.kind === 'shot' && event.hit && event.headshot === false) - .map((event) => ({ at: event.at, outcomeKey: null })); - case 'headshot_kill': - return events - .filter((event): event is EnemyKilledEvent => event.kind === 'enemy_killed' && event.headshot === true) - .map((event) => ({ at: event.at, outcomeKey: null })); - case 'weapon_kill': - return events - .filter((event) => event.kind === 'enemy_killed') - .map((event) => ({ at: event.at, outcomeKey: null })); - case 'target_hit_within_distance': - if ( - !finiteNumber(assumptions.distanceMeters) - || assumptions.distanceMeters > trigger.params.meters + EPSILON - ) return []; - return events - .filter((event): event is ShotEvent => - event.kind === 'shot' && event.hit && Boolean(event.targetId) - ) - .map((event) => ({ at: event.at, outcomeKey: null })); - case 'damage_taken': - return events - .filter((event) => event.kind === 'damage_taken') - .map((event) => ({ at: event.at, outcomeKey: null })); - case 'weapon_swap': - return events - .filter((event) => event.kind === 'weapon_swapped') - .map((event) => ({ at: event.at, outcomeKey: null })); - case 'reload': - return events - .filter((event) => event.kind === 'reload_started') - .map((event) => ({ at: event.at, outcomeKey: null })); - case 'while_firing': - case 'health_threshold_bands': - case 'within_distance': - case 'extra_health_active': - return []; - } + switch (trigger.action) { + case 'same_target_hits_within_window': { + const params = trigger.params; + const hits = new Map(); + const candidates: CandidateActivation[] = []; + for (const event of events) { + if (event.kind !== 'shot' || !event.hit || !event.targetId) continue; + const recent = (hits.get(event.targetId) ?? []).filter( + (at) => event.at - at <= params.windowSeconds + EPSILON, + ); + recent.push(event.at); + if (recent.length >= params.hitsRequired) { + candidates.push({ at: event.at, outcomeKey: null }); + hits.set(event.targetId, []); + } else { + hits.set(event.targetId, recent); + } + } + return candidates; + } + case 'distinct_enemies_within_window': { + const params = trigger.params; + let recent = new Map(); + const candidates: CandidateActivation[] = []; + for (const event of events) { + if (event.kind !== 'shot' || !event.hit || !event.targetId) continue; + recent = new Map( + [...recent].filter( + ([, at]) => event.at - at <= params.windowSeconds + EPSILON, + ), + ); + recent.set(event.targetId, event.at); + if (recent.size >= params.enemiesRequired) { + candidates.push({ at: event.at, outcomeKey: null }); + recent.clear(); + } + } + return candidates; + } + case 'consecutive_same_target_hits': { + const params = trigger.params; + let targetId: string | null = null; + let count = 0; + const candidates: CandidateActivation[] = []; + for (const event of events) { + if (event.kind !== 'shot') continue; + if (!event.hit || !event.targetId) { + targetId = null; + count = 0; + continue; + } + if (event.targetId !== targetId) { + targetId = event.targetId; + count = 1; + } else { + count += 1; + } + if (count >= params.hitsRequired) { + candidates.push({ at: event.at, outcomeKey: null }); + count = 0; + } + } + return candidates; + } + case 'continuous_firing_duration': { + return firingSegments(events, assumptions.encounterSeconds) + .filter( + (segment) => + segment.end - segment.start + EPSILON >= trigger.params.seconds, + ) + .map((segment) => ({ + at: segment.start + trigger.params.seconds, + outcomeKey: null, + })); + } + case 'headshot': + return events + .filter( + (event): event is ShotEvent => + event.kind === 'shot' && event.hit && event.headshot === true, + ) + .map((event) => ({ at: event.at, outcomeKey: null })); + case 'body_shot': + return events + .filter( + (event): event is ShotEvent => + event.kind === 'shot' && event.hit && event.headshot === false, + ) + .map((event) => ({ at: event.at, outcomeKey: null })); + case 'headshot_kill': + return events + .filter( + (event): event is EnemyKilledEvent => + event.kind === 'enemy_killed' && event.headshot === true, + ) + .map((event) => ({ at: event.at, outcomeKey: null })); + case 'weapon_kill': + return events + .filter((event) => event.kind === 'enemy_killed') + .map((event) => ({ at: event.at, outcomeKey: null })); + case 'target_hit_within_distance': + if ( + !finiteNumber(assumptions.distanceMeters) || + assumptions.distanceMeters > trigger.params.meters + EPSILON + ) + return []; + return events + .filter( + (event): event is ShotEvent => + event.kind === 'shot' && event.hit && Boolean(event.targetId), + ) + .map((event) => ({ at: event.at, outcomeKey: null })); + case 'damage_taken': + return events + .filter((event) => event.kind === 'damage_taken') + .map((event) => ({ at: event.at, outcomeKey: null })); + case 'weapon_swap': + return events + .filter((event) => event.kind === 'weapon_swapped') + .map((event) => ({ at: event.at, outcomeKey: null })); + case 'reload': + return events + .filter((event) => event.kind === 'reload_started') + .map((event) => ({ at: event.at, outcomeKey: null })); + case 'while_firing': + case 'health_threshold_bands': + case 'within_distance': + case 'extra_health_active': + return []; + } } -function directRateCandidates(procsPerMinute: number, encounterSeconds: number): CandidateActivation[] { - if (!finiteNumber(procsPerMinute) || procsPerMinute <= 0) return []; - const interval = 60 / procsPerMinute; - const candidates: CandidateActivation[] = []; - for (let at = interval; at <= encounterSeconds + EPSILON; at += interval) { - candidates.push({ at, outcomeKey: null }); - } - return candidates; +function directRateCandidates( + procsPerMinute: number, + encounterSeconds: number, +): CandidateActivation[] { + if (!finiteNumber(procsPerMinute) || procsPerMinute <= 0) return []; + const interval = 60 / procsPerMinute; + const candidates: CandidateActivation[] = []; + for (let at = interval; at <= encounterSeconds + EPSILON; at += interval) { + candidates.push({ at, outcomeKey: null }); + } + return candidates; } -function strongestHealthOutcome(params: HealthThresholdParams, healthPercent: number | undefined) { - if (!finiteNumber(healthPercent)) return null; - const current = clamp(healthPercent, 0, 100); - return [...params.bands] - .sort((left, right) => left.thresholdPercent - right.thresholdPercent) - .find((band) => current <= band.thresholdPercent + EPSILON)?.outcomeKey ?? null; +function strongestHealthOutcome( + params: HealthThresholdParams, + healthPercent: number | undefined, +) { + if (!finiteNumber(healthPercent)) return null; + const current = clamp(healthPercent, 0, 100); + return ( + [...params.bands] + .sort((left, right) => left.thresholdPercent - right.thresholdPercent) + .find((band) => current <= band.thresholdPercent + EPSILON)?.outcomeKey ?? + null + ); } function persistentCondition( - trigger: TriggerConfiguration, - assumptions: ConditionalAssumptions + trigger: TriggerConfiguration, + assumptions: ConditionalAssumptions, ): { active: boolean; outcomeKey: string | null } { - switch (trigger.action) { - case 'health_threshold_bands': { - const outcomeKey = strongestHealthOutcome(trigger.params, assumptions.healthPercent); - return { active: outcomeKey !== null, outcomeKey }; - } - case 'within_distance': - return { - active: finiteNumber(assumptions.distanceMeters) - && assumptions.distanceMeters <= trigger.params.meters + EPSILON, - outcomeKey: null - }; - case 'extra_health_active': - return { active: assumptions.extraHealthActive === true, outcomeKey: null }; - default: - return { active: false, outcomeKey: null }; - } + switch (trigger.action) { + case 'health_threshold_bands': { + const outcomeKey = strongestHealthOutcome( + trigger.params, + assumptions.healthPercent, + ); + return { active: outcomeKey !== null, outcomeKey }; + } + case 'within_distance': + return { + active: + finiteNumber(assumptions.distanceMeters) && + assumptions.distanceMeters <= trigger.params.meters + EPSILON, + outcomeKey: null, + }; + case 'extra_health_active': + return { + active: assumptions.extraHealthActive === true, + outcomeKey: null, + }; + default: + return { active: false, outcomeKey: null }; + } } function outcomeEventsAt( - program: ConditionalProgram, - at: number, - stacks: number, - outcomeKey: string | null + program: ConditionalProgram, + at: number, + stacks: number, + outcomeKey: string | null, ): ConditionalOutcomeEvent[] { - return program.outcomes - .filter((outcome) => outcome.application !== 'active_modifier') - .filter((outcome) => outcomeKey === null || outcome.outcomeKey === outcomeKey) - .map((outcome) => ({ - at, - effectiveAfter: at + EPSILON, - outcomeId: outcome.id, - application: outcome.application, - scope: outcome.scope, - value: outcome.value * stacks, - basis: outcome.application === 'direct_damage' ? outcome.basis : null, - stacks - })); + return program.outcomes + .filter((outcome) => outcome.application !== 'active_modifier') + .filter( + (outcome) => outcomeKey === null || outcome.outcomeKey === outcomeKey, + ) + .map((outcome) => ({ + at, + effectiveAfter: at + EPSILON, + outcomeId: outcome.id, + application: outcome.application, + scope: outcome.scope, + value: outcome.value * stacks, + basis: outcome.application === 'direct_damage' ? outcome.basis : null, + stacks, + })); } -function summarizeIntervals(intervals: ConditionalStateInterval[], encounterSeconds: number) { - if (encounterSeconds <= 0) return { averageStacks: 0, uptimePercent: 0 }; - let stackSeconds = 0; - let activeSeconds = 0; - for (const interval of intervals) { - const duration = Math.max(0, interval.end - interval.start); - stackSeconds += duration * interval.stacks; - if (interval.stacks > 0) activeSeconds += duration; - } - return { - averageStacks: stackSeconds / encounterSeconds, - uptimePercent: clamp(activeSeconds / encounterSeconds * 100, 0, 100) - }; +function summarizeIntervals( + intervals: ConditionalStateInterval[], + encounterSeconds: number, +) { + if (encounterSeconds <= 0) return { averageStacks: 0, uptimePercent: 0 }; + let stackSeconds = 0; + let activeSeconds = 0; + for (const interval of intervals) { + const duration = Math.max(0, interval.end - interval.start); + stackSeconds += duration * interval.stacks; + if (interval.stacks > 0) activeSeconds += duration; + } + return { + averageStacks: stackSeconds / encounterSeconds, + uptimePercent: clamp((activeSeconds / encounterSeconds) * 100, 0, 100), + }; } function stateIntervals( - activations: ConditionalActivation[], - encounterSeconds: number + activations: ConditionalActivation[], + encounterSeconds: number, ): ConditionalStateInterval[] { - const boundaries = new Set([0, encounterSeconds]); - for (const activation of activations) { - boundaries.add(clamp(activation.effectiveAfter, 0, encounterSeconds)); - boundaries.add(clamp(activation.expiresAt, 0, encounterSeconds)); - } - const ordered = [...boundaries].sort((left, right) => left - right); - const intervals: ConditionalStateInterval[] = []; - for (let index = 0; index < ordered.length - 1; index += 1) { - const start = ordered[index]; - const end = ordered[index + 1]; - if (end - start <= EPSILON) continue; - const active = [...activations] - .reverse() - .find((activation) => - activation.effectiveAfter <= start + EPSILON - && activation.expiresAt > start + EPSILON - ); - if (!active || active.stacksAfter <= 0) continue; - const prior = intervals.at(-1); - if ( - prior - && Math.abs(prior.end - start) <= EPSILON - && prior.stacks === active.stacksAfter - && prior.outcomeKey === active.outcomeKey - ) { - prior.end = end; - } else { - intervals.push({ start, end, stacks: active.stacksAfter, outcomeKey: active.outcomeKey }); - } - } - return intervals; + const boundaries = new Set([0, encounterSeconds]); + for (const activation of activations) { + boundaries.add(clamp(activation.effectiveAfter, 0, encounterSeconds)); + boundaries.add(clamp(activation.expiresAt, 0, encounterSeconds)); + } + const ordered = [...boundaries].sort((left, right) => left - right); + const intervals: ConditionalStateInterval[] = []; + for (let index = 0; index < ordered.length - 1; index += 1) { + const start = ordered[index]; + const end = ordered[index + 1]; + if (end - start <= EPSILON) continue; + const active = [...activations] + .reverse() + .find( + (activation) => + activation.effectiveAfter <= start + EPSILON && + activation.expiresAt > start + EPSILON, + ); + if (!active || active.stacksAfter <= 0) continue; + const prior = intervals.at(-1); + if ( + prior && + Math.abs(prior.end - start) <= EPSILON && + prior.stacks === active.stacksAfter && + prior.outcomeKey === active.outcomeKey + ) { + prior.end = end; + } else { + intervals.push({ + start, + end, + stacks: active.stacksAfter, + outcomeKey: active.outcomeKey, + }); + } + } + return intervals; } function evaluateCandidates( - program: ConditionalProgram, - candidates: CandidateActivation[], - encounterSeconds: number + program: ConditionalProgram, + candidates: CandidateActivation[], + encounterSeconds: number, ) { - const activations: ConditionalActivation[] = []; - const outcomeEvents: ConditionalOutcomeEvent[] = []; - let stacks = 0; - let expiresAt = 0; - let cooldownUntil = 0; - for (const candidate of candidates.sort((left, right) => left.at - right.at)) { - if (candidate.at < 0 || candidate.at > encounterSeconds + EPSILON) continue; - if (candidate.at + EPSILON < cooldownUntil) continue; - if (candidate.at + EPSILON >= expiresAt) stacks = 0; - const stacksBefore = stacks; - stacks = Math.min( - program.lifecycle.maxStacks, - stacks + program.lifecycle.stacksPerActivation - ); - expiresAt = candidate.at + program.lifecycle.durationSeconds; - cooldownUntil = candidate.at + program.lifecycle.cooldownSeconds; - const activation: ConditionalActivation = { - at: candidate.at, - effectiveAfter: candidate.at + EPSILON, - stacksBefore, - stacksAfter: stacks, - expiresAt, - cooldownUntil, - outcomeKey: candidate.outcomeKey - }; - activations.push(activation); - outcomeEvents.push(...outcomeEventsAt(program, candidate.at, stacks, candidate.outcomeKey)); - } - return { activations, outcomeEvents }; + const activations: ConditionalActivation[] = []; + const outcomeEvents: ConditionalOutcomeEvent[] = []; + let stacks = 0; + let expiresAt = 0; + let cooldownUntil = 0; + for (const candidate of candidates.sort( + (left, right) => left.at - right.at, + )) { + if (candidate.at < 0 || candidate.at > encounterSeconds + EPSILON) continue; + if (candidate.at + EPSILON < cooldownUntil) continue; + if (candidate.at + EPSILON >= expiresAt) stacks = 0; + const stacksBefore = stacks; + stacks = Math.min( + program.lifecycle.maxStacks, + stacks + program.lifecycle.stacksPerActivation, + ); + expiresAt = candidate.at + program.lifecycle.durationSeconds; + cooldownUntil = candidate.at + program.lifecycle.cooldownSeconds; + const activation: ConditionalActivation = { + at: candidate.at, + effectiveAfter: candidate.at + EPSILON, + stacksBefore, + stacksAfter: stacks, + expiresAt, + cooldownUntil, + outcomeKey: candidate.outcomeKey, + }; + activations.push(activation); + outcomeEvents.push( + ...outcomeEventsAt(program, candidate.at, stacks, candidate.outcomeKey), + ); + } + return { activations, outcomeEvents }; } function evaluatePersistent( - program: ConditionalProgram, - assumptions: ConditionalAssumptions, - outcomeKey: string | null + program: ConditionalProgram, + assumptions: ConditionalAssumptions, + outcomeKey: string | null, ) { - const stacks = program.lifecycle.maxStacks; - const activation: ConditionalActivation = { - at: 0, - effectiveAfter: 0, - stacksBefore: 0, - stacksAfter: stacks, - expiresAt: assumptions.encounterSeconds, - cooldownUntil: 0, - outcomeKey - }; - const intervals = assumptions.encounterSeconds > 0 - ? [{ start: 0, end: assumptions.encounterSeconds, stacks, outcomeKey }] - : []; - return { - activations: [activation], - intervals, - outcomeEvents: outcomeEventsAt(program, 0, stacks, outcomeKey) - }; + const stacks = program.lifecycle.maxStacks; + const activation: ConditionalActivation = { + at: 0, + effectiveAfter: 0, + stacksBefore: 0, + stacksAfter: stacks, + expiresAt: assumptions.encounterSeconds, + cooldownUntil: 0, + outcomeKey, + }; + const intervals = + assumptions.encounterSeconds > 0 + ? [{ start: 0, end: assumptions.encounterSeconds, stacks, outcomeKey }] + : []; + return { + activations: [activation], + intervals, + outcomeEvents: outcomeEventsAt(program, 0, stacks, outcomeKey), + }; } function evaluateWhileFiring( - program: ConditionalProgram, - events: ConditionalEvent[], - assumptions: ConditionalAssumptions + program: ConditionalProgram, + events: ConditionalEvent[], + assumptions: ConditionalAssumptions, ) { - const segments = firingSegments(events, assumptions.encounterSeconds); - const stacks = program.lifecycle.maxStacks; - const activations = segments.map((segment) => ({ - at: segment.start, - effectiveAfter: segment.start + EPSILON, - stacksBefore: 0, - stacksAfter: stacks, - expiresAt: segment.end, - cooldownUntil: segment.start, - outcomeKey: null - })); - return { - activations, - intervals: segments.map((segment) => ({ - start: segment.start, - end: segment.end, - stacks, - outcomeKey: null - })), - outcomeEvents: activations.flatMap((activation) => - outcomeEventsAt(program, activation.at, stacks, null) - ) - }; + const segments = firingSegments(events, assumptions.encounterSeconds); + const stacks = program.lifecycle.maxStacks; + const activations = segments.map((segment) => ({ + at: segment.start, + effectiveAfter: segment.start + EPSILON, + stacksBefore: 0, + stacksAfter: stacks, + expiresAt: segment.end, + cooldownUntil: segment.start, + outcomeKey: null, + })); + return { + activations, + intervals: segments.map((segment) => ({ + start: segment.start, + end: segment.end, + stacks, + outcomeKey: null, + })), + outcomeEvents: activations.flatMap((activation) => + outcomeEventsAt(program, activation.at, stacks, null), + ), + }; } -function invalidEvaluation(programId: string, mode: ConditionalMode, errors: string[]): ConditionalEvaluation { - return { - status: 'INVALID', - errors, - programId, - mode, - activations: [], - intervals: [], - outcomeEvents: [], - activationCount: 0, - maxStacksReached: 0, - averageStacks: 0, - uptimePercent: 0, - selectedOutcomeKey: null - }; +function invalidEvaluation( + programId: string, + mode: ConditionalMode, + errors: string[], +): ConditionalEvaluation { + return { + status: 'INVALID', + errors, + programId, + mode, + activations: [], + intervals: [], + outcomeEvents: [], + activationCount: 0, + maxStacksReached: 0, + averageStacks: 0, + uptimePercent: 0, + selectedOutcomeKey: null, + }; } export function evaluateConditionalProgram( - program: ConditionalProgram, - events: ConditionalEvent[], - assumptions: ConditionalAssumptions + program: ConditionalProgram, + events: ConditionalEvent[], + assumptions: ConditionalAssumptions, ): ConditionalEvaluation { - const validation = validateConditionalProgram(program); - const assumptionErrors: string[] = []; - if (!finiteNumber(assumptions.encounterSeconds) || assumptions.encounterSeconds <= 0) { - assumptionErrors.push('Encounter seconds must be greater than 0.'); - } - if ( - assumptions.directProcsPerMinute !== undefined - && (!finiteNumber(assumptions.directProcsPerMinute) || assumptions.directProcsPerMinute < 0) - ) { - assumptionErrors.push('Direct procs per minute must be 0 or greater.'); - } - if (!validation.ok || assumptionErrors.length) { - return invalidEvaluation( - program.id, - assumptions.mode, - [...(validation.ok ? [] : validation.errors), ...assumptionErrors] - ); - } - if (assumptions.mode === 'off') { - return { - ...invalidEvaluation(program.id, assumptions.mode, []), - status: 'OK' - }; - } - const ordered = orderedEvents(events, assumptions.encounterSeconds); - let activations: ConditionalActivation[] = []; - let intervals: ConditionalStateInterval[] = []; - let outcomeEvents: ConditionalOutcomeEvent[] = []; - let selectedOutcomeKey: string | null = null; + const validation = validateConditionalProgram(program); + const assumptionErrors: string[] = []; + if ( + !finiteNumber(assumptions.encounterSeconds) || + assumptions.encounterSeconds <= 0 + ) { + assumptionErrors.push('Encounter seconds must be greater than 0.'); + } + if ( + assumptions.directProcsPerMinute !== undefined && + (!finiteNumber(assumptions.directProcsPerMinute) || + assumptions.directProcsPerMinute < 0) + ) { + assumptionErrors.push('Direct procs per minute must be 0 or greater.'); + } + if (!validation.ok || assumptionErrors.length) { + return invalidEvaluation(program.id, assumptions.mode, [ + ...(validation.ok ? [] : validation.errors), + ...assumptionErrors, + ]); + } + if (assumptions.mode === 'off') { + return { + ...invalidEvaluation(program.id, assumptions.mode, []), + status: 'OK', + }; + } + const ordered = orderedEvents(events, assumptions.encounterSeconds); + let activations: ConditionalActivation[] = []; + let intervals: ConditionalStateInterval[] = []; + let outcomeEvents: ConditionalOutcomeEvent[] = []; + let selectedOutcomeKey: string | null = null; - if (assumptions.mode === 'peak') { - selectedOutcomeKey = program.trigger.action === 'health_threshold_bands' - ? strongestHealthOutcome(program.trigger.params, assumptions.healthPercent) - ?? [...program.trigger.params.bands] - .sort((left, right) => left.thresholdPercent - right.thresholdPercent)[0]?.outcomeKey - ?? null - : null; - const persistent = evaluatePersistent(program, assumptions, selectedOutcomeKey); - ({ activations, intervals, outcomeEvents } = persistent); - } else if ( - finiteNumber(assumptions.directProcsPerMinute) - && assumptions.directProcsPerMinute > 0 - ) { - const evaluated = evaluateCandidates( - program, - directRateCandidates(assumptions.directProcsPerMinute, assumptions.encounterSeconds), - assumptions.encounterSeconds - ); - activations = evaluated.activations; - outcomeEvents = evaluated.outcomeEvents; - intervals = stateIntervals(activations, assumptions.encounterSeconds); - } else if (program.trigger.action === 'while_firing') { - const evaluated = evaluateWhileFiring(program, ordered, assumptions); - ({ activations, intervals, outcomeEvents } = evaluated); - } else { - const condition = persistentCondition(program.trigger, assumptions); - if (condition.active) { - selectedOutcomeKey = condition.outcomeKey; - const persistent = evaluatePersistent(program, assumptions, condition.outcomeKey); - ({ activations, intervals, outcomeEvents } = persistent); - } else { - const evaluated = evaluateCandidates( - program, - eventCandidates(program.trigger, ordered, assumptions), - assumptions.encounterSeconds - ); - activations = evaluated.activations; - outcomeEvents = evaluated.outcomeEvents; - intervals = stateIntervals(activations, assumptions.encounterSeconds); - } - } - const summary = summarizeIntervals(intervals, assumptions.encounterSeconds); - return { - status: 'OK', - errors: [], - programId: program.id, - mode: assumptions.mode, - activations, - intervals, - outcomeEvents, - activationCount: activations.length, - maxStacksReached: Math.max(0, ...activations.map((activation) => activation.stacksAfter)), - averageStacks: summary.averageStacks, - uptimePercent: summary.uptimePercent, - selectedOutcomeKey - }; + if (assumptions.mode === 'peak') { + selectedOutcomeKey = + program.trigger.action === 'health_threshold_bands' + ? (strongestHealthOutcome( + program.trigger.params, + assumptions.healthPercent, + ) ?? + [...program.trigger.params.bands].sort( + (left, right) => left.thresholdPercent - right.thresholdPercent, + )[0]?.outcomeKey ?? + null) + : null; + const persistent = evaluatePersistent( + program, + assumptions, + selectedOutcomeKey, + ); + ({ activations, intervals, outcomeEvents } = persistent); + } else if ( + finiteNumber(assumptions.directProcsPerMinute) && + assumptions.directProcsPerMinute > 0 + ) { + const evaluated = evaluateCandidates( + program, + directRateCandidates( + assumptions.directProcsPerMinute, + assumptions.encounterSeconds, + ), + assumptions.encounterSeconds, + ); + activations = evaluated.activations; + outcomeEvents = evaluated.outcomeEvents; + intervals = stateIntervals(activations, assumptions.encounterSeconds); + } else if (program.trigger.action === 'while_firing') { + const evaluated = evaluateWhileFiring(program, ordered, assumptions); + ({ activations, intervals, outcomeEvents } = evaluated); + } else { + const condition = persistentCondition(program.trigger, assumptions); + if (condition.active) { + selectedOutcomeKey = condition.outcomeKey; + const persistent = evaluatePersistent( + program, + assumptions, + condition.outcomeKey, + ); + ({ activations, intervals, outcomeEvents } = persistent); + } else { + const evaluated = evaluateCandidates( + program, + eventCandidates(program.trigger, ordered, assumptions), + assumptions.encounterSeconds, + ); + activations = evaluated.activations; + outcomeEvents = evaluated.outcomeEvents; + intervals = stateIntervals(activations, assumptions.encounterSeconds); + } + } + const summary = summarizeIntervals(intervals, assumptions.encounterSeconds); + return { + status: 'OK', + errors: [], + programId: program.id, + mode: assumptions.mode, + activations, + intervals, + outcomeEvents, + activationCount: activations.length, + maxStacksReached: Math.max( + 0, + ...activations.map((activation) => activation.stacksAfter), + ), + averageStacks: summary.averageStacks, + uptimePercent: summary.uptimePercent, + selectedOutcomeKey, + }; } diff --git a/src/lib/calculations/mechanic-editor.test.ts b/src/lib/calculations/mechanic-editor.test.ts index e2b12f5..de40c40 100644 --- a/src/lib/calculations/mechanic-editor.test.ts +++ b/src/lib/calculations/mechanic-editor.test.ts @@ -1,642 +1,738 @@ import { describe, expect, it } from 'vitest'; import { - currentHealthPercentBandPredicate, - eventFieldComparisonPredicate, - parseConditionalMechanicDraft, - readCurrentHealthPercentBand, - readEventFieldComparison, - registeredEventFields, - readStatePoolThreshold, - statePoolThresholdPredicate, - type ConditionalMechanicDraftV1 + currentHealthPercentBandPredicate, + eventFieldComparisonPredicate, + parseConditionalMechanicDraft, + readCurrentHealthPercentBand, + readEventFieldComparison, + registeredEventFields, + readStatePoolThreshold, + statePoolThresholdPredicate, + type ConditionalMechanicDraftV1, } from './mechanic-editor.js'; const draft = (): ConditionalMechanicDraftV1 => ({ - version: 1, - simulationReadiness: 'ready', - scenarioInputs: [], - pools: [{ - key: 'effect', - label: 'Effect', - stateKind: 'stack', - scope: 'agent', - initial: 0, - min: 0, - max: 10, - bestCaseValue: 10, - expiresAfterSeconds: 10 - }], - clauses: [{ - key: 'gain-effect', - label: 'Gain Effect', - event: 'weapon_hit', - recipeKey: 'same_target_hits_within_window', - if: { kind: 'exists', value: { source: 'event', path: 'targetId' } }, - reducer: { - kind: 'sliding_count', - threshold: 9, - windowSeconds: 3, - groupBy: { source: 'event', path: 'targetId' } - }, - cooldownSeconds: 2, - weight: 1, - mutations: [{ kind: 'add', pool: 'effect', value: 1 }], - outcomes: [{ - key: 'weapon-damage', - label: 'Weapon Damage', - phase: 'while_active', - kind: 'attribute_modifier', - scope: 'equipped_weapon', - basis: 'flat', - attributeId: 'weapon-damage', - statePool: 'effect', - perStack: true, - descriptionSlot: 1, - variants: { - I: { value: 3 }, - II: { value: 4.2 } - } - }] - }] + version: 1, + simulationReadiness: 'ready', + scenarioInputs: [], + pools: [ + { + key: 'effect', + label: 'Effect', + stateKind: 'stack', + scope: 'agent', + initial: 0, + min: 0, + max: 10, + bestCaseValue: 10, + expiresAfterSeconds: 10, + }, + ], + clauses: [ + { + key: 'gain-effect', + label: 'Gain Effect', + event: 'weapon_hit', + recipeKey: 'same_target_hits_within_window', + if: { kind: 'exists', value: { source: 'event', path: 'targetId' } }, + reducer: { + kind: 'sliding_count', + threshold: 9, + windowSeconds: 3, + groupBy: { source: 'event', path: 'targetId' }, + }, + cooldownSeconds: 2, + weight: 1, + mutations: [{ kind: 'add', pool: 'effect', value: 1 }], + outcomes: [ + { + key: 'weapon-damage', + label: 'Weapon Damage', + phase: 'while_active', + kind: 'attribute_modifier', + scope: 'equipped_weapon', + basis: 'flat', + attributeId: 'weapon-damage', + statePool: 'effect', + perStack: true, + descriptionSlot: 1, + variants: { + I: { value: 3 }, + II: { value: 4.2 }, + }, + }, + ], + }, + ], }); describe('conditional mechanic authoring payload', () => { - it('resolves and validates variant-specific pool expiration durations', () => { - const value = draft(); - value.pools[0] = { - ...value.pools[0], - expiresAfterSeconds: undefined, - expiresAfterSecondsVariants: { I: 12, II: 18 } - }; - const result = parseConditionalMechanicDraft(value, { variantKeys: ['I', 'II'] }); - expect(result.ok).toBe(true); - if (!result.ok) return; - expect(result.value.programs.I.pools?.[0].expiresAfterSeconds).toBe(12); - expect(result.value.programs.II.pools?.[0].expiresAfterSeconds).toBe(18); + it('resolves and validates variant-specific pool expiration durations', () => { + const value = draft(); + value.pools[0] = { + ...value.pools[0], + expiresAfterSeconds: undefined, + expiresAfterSecondsVariants: { I: 12, II: 18 }, + }; + const result = parseConditionalMechanicDraft(value, { + variantKeys: ['I', 'II'], + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.programs.I.pools?.[0].expiresAfterSeconds).toBe(12); + expect(result.value.programs.II.pools?.[0].expiresAfterSeconds).toBe(18); - value.pools[0].expiresAfterSecondsVariants = { I: 12 }; - const invalid = parseConditionalMechanicDraft(value, { variantKeys: ['I', 'II'] }); - expect(invalid.ok).toBe(false); - if (!invalid.ok) { - expect(invalid.errors).toContain( - 'State pool 1 variant expiration durations must define one positive number for each variant.' - ); - } - }); + value.pools[0].expiresAfterSecondsVariants = { I: 12 }; + const invalid = parseConditionalMechanicDraft(value, { + variantKeys: ['I', 'II'], + }); + expect(invalid.ok).toBe(false); + if (!invalid.ok) { + expect(invalid.errors).toContain( + 'State pool 1 variant expiration durations must define one positive number for each variant.', + ); + } + }); - it('round-trips strict manual current Health percentage band editor state', () => { - const predicate = currentHealthPercentBandPredicate({ minimum: 25, maximum: 50 }); - expect(readCurrentHealthPercentBand(predicate)).toEqual({ minimum: 25, maximum: 50 }); - expect(predicate).toEqual({ - kind: 'all', - predicates: [{ - kind: 'compare', - left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' }, - operator: 'gte', - right: 25 - }, { - kind: 'compare', - left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' }, - operator: 'lt', - right: 50 - }] - }); - expect(readCurrentHealthPercentBand({ - kind: 'compare', - left: { source: 'event', path: 'values.healthPercent' }, - operator: 'lt', - right: 50 - })).toBeNull(); - }); + it('round-trips strict manual current Health percentage band editor state', () => { + const predicate = currentHealthPercentBandPredicate({ + minimum: 25, + maximum: 50, + }); + expect(readCurrentHealthPercentBand(predicate)).toEqual({ + minimum: 25, + maximum: 50, + }); + expect(predicate).toEqual({ + kind: 'all', + predicates: [ + { + kind: 'compare', + left: { + source: 'combat', + path: 'currentHealthPercent', + timing: 'current', + }, + operator: 'gte', + right: 25, + }, + { + kind: 'compare', + left: { + source: 'combat', + path: 'currentHealthPercent', + timing: 'current', + }, + operator: 'lt', + right: 50, + }, + ], + }); + expect( + readCurrentHealthPercentBand({ + kind: 'compare', + left: { source: 'event', path: 'values.healthPercent' }, + operator: 'lt', + right: 50, + }), + ).toBeNull(); + }); - it('round-trips editable event scalar comparisons without claiming advanced predicates', () => { - for (const value of ['moving', 4, true]) { - const comparison = { path: 'values.state', operator: 'eq' as const, value }; - const predicate = eventFieldComparisonPredicate(comparison); - expect(readEventFieldComparison(predicate)).toEqual(comparison); - } - expect(readEventFieldComparison({ - kind: 'compare', - left: { source: 'event', path: 'values.state' }, - operator: 'eq', - right: { source: 'event', path: 'values.previousState' } - })).toBeNull(); - expect(readEventFieldComparison({ - kind: 'all', - predicates: [] - })).toBeNull(); - }); + it('round-trips editable event scalar comparisons without claiming advanced predicates', () => { + for (const value of ['moving', 4, true]) { + const comparison = { + path: 'values.state', + operator: 'eq' as const, + value, + }; + const predicate = eventFieldComparisonPredicate(comparison); + expect(readEventFieldComparison(predicate)).toEqual(comparison); + } + expect( + readEventFieldComparison({ + kind: 'compare', + left: { source: 'event', path: 'values.state' }, + operator: 'eq', + right: { source: 'event', path: 'values.previousState' }, + }), + ).toBeNull(); + expect( + readEventFieldComparison({ + kind: 'all', + predicates: [], + }), + ).toBeNull(); + }); - it('exposes typed Extra Health fields and parses their manual editor state', () => { - expect(registeredEventFields('extra_health_changed')).toEqual([ - { path: 'values.hasExtraHealth', label: 'Has Extra Health', type: 'boolean', required: true }, - { path: 'values.currentExtraHealth', label: 'Current Extra Health', type: 'number', minimum: 0 } - ]); - const value = draft(); - value.clauses[0].event = 'extra_health_changed'; - value.clauses[0].recipeKey = undefined; - value.clauses[0].reducer = { kind: 'none' }; - value.clauses[0].if = eventFieldComparisonPredicate({ - path: 'values.hasExtraHealth', - operator: 'eq', - value: true - }); - const result = parseConditionalMechanicDraft(value, { - variantKeys: ['I', 'II'], - allowedAttributeIds: new Set(['weapon-damage']) - }); - expect(result.ok).toBe(true); - if (!result.ok) return; - expect(result.value.programs.I.clauses[0]).toMatchObject({ - when: 'extra_health_changed', - if: value.clauses[0].if - }); - }); + it('exposes typed Extra Health fields and parses their manual editor state', () => { + expect(registeredEventFields('extra_health_changed')).toEqual([ + { + path: 'values.hasExtraHealth', + label: 'Has Extra Health', + type: 'boolean', + required: true, + }, + { + path: 'values.currentExtraHealth', + label: 'Current Extra Health', + type: 'number', + minimum: 0, + }, + ]); + const value = draft(); + value.clauses[0].event = 'extra_health_changed'; + value.clauses[0].recipeKey = undefined; + value.clauses[0].reducer = { kind: 'none' }; + value.clauses[0].if = eventFieldComparisonPredicate({ + path: 'values.hasExtraHealth', + operator: 'eq', + value: true, + }); + const result = parseConditionalMechanicDraft(value, { + variantKeys: ['I', 'II'], + allowedAttributeIds: new Set(['weapon-damage']), + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.programs.I.clauses[0]).toMatchObject({ + when: 'extra_health_changed', + if: value.clauses[0].if, + }); + }); - it('rejects an unregistered Extra Health event field deterministically', () => { - const value = draft(); - value.clauses[0].event = 'extra_health_changed'; - value.clauses[0].recipeKey = undefined; - value.clauses[0].reducer = { kind: 'none' }; - value.clauses[0].if = eventFieldComparisonPredicate({ - path: 'values.extraHealthActive', - operator: 'eq', - value: true - }); - const result = parseConditionalMechanicDraft(value, { - variantKeys: ['I', 'II'], - allowedAttributeIds: new Set(['weapon-damage']) - }); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.errors).toEqual(expect.arrayContaining([ - expect.stringContaining('is not defined for Extra Health changed') - ])); - } - }); + it('rejects an unregistered Extra Health event field deterministically', () => { + const value = draft(); + value.clauses[0].event = 'extra_health_changed'; + value.clauses[0].recipeKey = undefined; + value.clauses[0].reducer = { kind: 'none' }; + value.clauses[0].if = eventFieldComparisonPredicate({ + path: 'values.extraHealthActive', + operator: 'eq', + value: true, + }); + const result = parseConditionalMechanicDraft(value, { + variantKeys: ['I', 'II'], + allowedAttributeIds: new Set(['weapon-damage']), + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors).toEqual( + expect.arrayContaining([ + expect.stringContaining('is not defined for Extra Health changed'), + ]), + ); + } + }); - it('round-trips state-pool thresholds and preserves unsupported targeted pools as advanced', () => { - const comparison = { pool: 'stacks', operator: 'gte' as const, value: 10 }; - const predicate = statePoolThresholdPredicate(comparison); - expect(readStatePoolThreshold(predicate)).toEqual(comparison); - expect(readStatePoolThreshold({ - kind: 'compare', - left: { source: 'pool', pool: 'stacks', entity: { kind: 'event_target' } }, - operator: 'gt', - right: 0 - })).toBeNull(); - }); + it('round-trips state-pool thresholds and preserves unsupported targeted pools as advanced', () => { + const comparison = { pool: 'stacks', operator: 'gte' as const, value: 10 }; + const predicate = statePoolThresholdPredicate(comparison); + expect(readStatePoolThreshold(predicate)).toEqual(comparison); + expect( + readStatePoolThreshold({ + kind: 'compare', + left: { + source: 'pool', + pool: 'stacks', + entity: { kind: 'event_target' }, + }, + operator: 'gt', + right: 0, + }), + ).toBeNull(); + }); - it('parses movement-state and stack-threshold authoring predicates unchanged', () => { - const value = draft(); - value.clauses[0].event = 'movement_snapshot'; - value.clauses[0].if = eventFieldComparisonPredicate({ - path: 'values.state', - operator: 'eq', - value: 'sprinting' - }); - value.clauses.push({ - ...structuredClone(value.clauses[0]), - key: 'consume-stacks', - event: 'shot_fired', - if: statePoolThresholdPredicate({ - pool: 'effect', - operator: 'gt', - value: 0 - }) - }); - const result = parseConditionalMechanicDraft(value, { - variantKeys: ['I', 'II'], - allowedAttributeIds: new Set(['weapon-damage']) - }); - expect(result.ok).toBe(true); - if (!result.ok) return; - expect(result.value.draft.clauses.map((clause) => clause.if)).toEqual( - value.clauses.map((clause) => clause.if) - ); - expect(result.value.programs.I.clauses.map((clause) => clause.if)).toEqual( - value.clauses.map((clause) => clause.if) - ); - }); + it('parses movement-state and stack-threshold authoring predicates unchanged', () => { + const value = draft(); + value.clauses[0].event = 'movement_snapshot'; + value.clauses[0].if = eventFieldComparisonPredicate({ + path: 'values.state', + operator: 'eq', + value: 'sprinting', + }); + value.clauses.push({ + ...structuredClone(value.clauses[0]), + key: 'consume-stacks', + event: 'shot_fired', + if: statePoolThresholdPredicate({ + pool: 'effect', + operator: 'gt', + value: 0, + }), + }); + const result = parseConditionalMechanicDraft(value, { + variantKeys: ['I', 'II'], + allowedAttributeIds: new Set(['weapon-damage']), + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.draft.clauses.map((clause) => clause.if)).toEqual( + value.clauses.map((clause) => clause.if), + ); + expect(result.value.programs.I.clauses.map((clause) => clause.if)).toEqual( + value.clauses.map((clause) => clause.if), + ); + }); - it('accepts an empty captured-only program for deferred modeling', () => { - const partial: ConditionalMechanicDraftV1 = { - version: 1, - simulationReadiness: 'captured_only', - scenarioInputs: [], - pools: [], - clauses: [] - }; - const result = parseConditionalMechanicDraft(partial, { - variantKeys: ['I', 'II'] - }); - expect(result.ok).toBe(true); - if (!result.ok) return; - expect(result.value.draft).toEqual(partial); - expect(result.value.programs.I.clauses).toEqual([]); - expect(result.value.programs.II.clauses).toEqual([]); - }); + it('accepts an empty captured-only program for deferred modeling', () => { + const partial: ConditionalMechanicDraftV1 = { + version: 1, + simulationReadiness: 'captured_only', + scenarioInputs: [], + pools: [], + clauses: [], + }; + const result = parseConditionalMechanicDraft(partial, { + variantKeys: ['I', 'II'], + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.draft).toEqual(partial); + expect(result.value.programs.I.clauses).toEqual([]); + expect(result.value.programs.II.clauses).toEqual([]); + }); - it('requires a branch after a partial program is marked calculation-ready', () => { - const partial: ConditionalMechanicDraftV1 = { - version: 1, - simulationReadiness: 'ready', - scenarioInputs: [], - pools: [], - clauses: [] - }; - const result = parseConditionalMechanicDraft(partial, { - variantKeys: ['I', 'II'] - }); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.errors).toContain('A simulation-ready mechanic requires at least one clause.'); - } - }); + it('requires a branch after a partial program is marked calculation-ready', () => { + const partial: ConditionalMechanicDraftV1 = { + version: 1, + simulationReadiness: 'ready', + scenarioInputs: [], + pools: [], + clauses: [], + }; + const result = parseConditionalMechanicDraft(partial, { + variantKeys: ['I', 'II'], + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors).toContain( + 'A simulation-ready mechanic requires at least one clause.', + ); + } + }); - it('builds one validated runtime program per exact source variant', () => { - const result = parseConditionalMechanicDraft(draft(), { - variantKeys: ['I', 'II'], - programId: 'superior-firepower', - programName: 'Superior Firepower', - allowedAttributeIds: new Set(['weapon-damage']), - allowedRecipeKeys: new Set(['same_target_hits_within_window']) - }); - expect(result.ok).toBe(true); - if (!result.ok) return; - expect(Object.keys(result.value.programs)).toEqual(['I', 'II']); - expect(result.value.programs.I.clauses[0]).toMatchObject({ - id: 'gain-effect', - when: 'weapon_hit', - recipeKey: 'same_target_hits_within_window', - cooldownSeconds: 2, - weight: 1, - outcomes: [{ - id: 'weapon-damage', - scope: 'equipped_weapon', - value: 3 - }] - }); - expect(result.value.programs.II.clauses[0].outcomes?.[0].value).toBe(4.2); - }); + it('builds one validated runtime program per exact source variant', () => { + const result = parseConditionalMechanicDraft(draft(), { + variantKeys: ['I', 'II'], + programId: 'superior-firepower', + programName: 'Superior Firepower', + allowedAttributeIds: new Set(['weapon-damage']), + allowedRecipeKeys: new Set(['same_target_hits_within_window']), + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(Object.keys(result.value.programs)).toEqual(['I', 'II']); + expect(result.value.programs.I.clauses[0]).toMatchObject({ + id: 'gain-effect', + when: 'weapon_hit', + recipeKey: 'same_target_hits_within_window', + cooldownSeconds: 2, + weight: 1, + outcomes: [ + { + id: 'weapon-damage', + scope: 'equipped_weapon', + value: 3, + }, + ], + }); + expect(result.value.programs.II.clauses[0].outcomes?.[0].value).toBe(4.2); + }); - it('preserves Happy Survival expressions, explicit cadence readiness, resources, and combat timing', () => { - const happy: ConditionalMechanicDraftV1 = { - version: 1, - simulationReadiness: 'requires_assumptions', - scenarioInputs: [{ - key: 'restoration-events-per-minute', - label: 'Restoration events per minute', - kind: 'number', - readiness: 'requires_assumption', - min: 0 - }], - pools: [{ - key: 'nearby-enemies', - label: 'Nearby Enemies', - stateKind: 'count', - scope: 'agent', - initial: 0, - min: 0, - max: 20, - bestCaseValue: 5 - }], - clauses: [{ - key: 'shot-health-exchange', - event: 'shot_fired', - reducer: { kind: 'none' }, - mutations: [{ kind: 'none' }], - outcomes: [ - { - key: 'health-cost', - label: 'Health Cost', - phase: 'on_event', - kind: 'resource_cost', - resource: 'health', - scope: 'self', - basis: 'max_health_percent', - variants: { base: { value: 2 } } - }, - { - key: 'bonus-damage', - label: 'Current Health Bonus Damage', - phase: 'on_event', - kind: 'direct_damage', - scope: 'target', - basis: 'current_health_percent', - combatTiming: 'current', - variants: { - base: { - value: 0.6, - expression: { - op: 'multiply', - left: 0.6, - right: { - op: 'add', - left: 1, - right: { - op: 'divide', - left: { - ref: { - source: 'equipment', - path: 'protocolTalentUpgrades' - } - }, - right: 5 - } - } - } - } - } - } - ] - }] - }; - const result = parseConditionalMechanicDraft(happy, { - variantKeys: ['base'], - programName: 'Happy Survival' - }); - expect(result.ok).toBe(true); - if (!result.ok) return; - expect(result.value.programs.base.clauses[0].outcomes).toEqual([ - expect.objectContaining({ kind: 'resource_cost', resource: 'health', value: 2 }), - expect.objectContaining({ - kind: 'direct_damage', - combatTiming: 'current', - value: expect.objectContaining({ - right: expect.objectContaining({ - right: expect.objectContaining({ - left: { - ref: { - source: 'equipment', - path: 'protocolTalentUpgrades' - } - } - }) - }) - }) - }) - ]); - }); + it('preserves Happy Survival expressions, explicit cadence readiness, resources, and combat timing', () => { + const happy: ConditionalMechanicDraftV1 = { + version: 1, + simulationReadiness: 'requires_assumptions', + scenarioInputs: [ + { + key: 'restoration-events-per-minute', + label: 'Restoration events per minute', + kind: 'number', + readiness: 'requires_assumption', + min: 0, + }, + ], + pools: [ + { + key: 'nearby-enemies', + label: 'Nearby Enemies', + stateKind: 'count', + scope: 'agent', + initial: 0, + min: 0, + max: 20, + bestCaseValue: 5, + }, + ], + clauses: [ + { + key: 'shot-health-exchange', + event: 'shot_fired', + reducer: { kind: 'none' }, + mutations: [{ kind: 'none' }], + outcomes: [ + { + key: 'health-cost', + label: 'Health Cost', + phase: 'on_event', + kind: 'resource_cost', + resource: 'health', + scope: 'self', + basis: 'max_health_percent', + variants: { base: { value: 2 } }, + }, + { + key: 'bonus-damage', + label: 'Current Health Bonus Damage', + phase: 'on_event', + kind: 'direct_damage', + scope: 'target', + basis: 'current_health_percent', + combatTiming: 'current', + variants: { + base: { + value: 0.6, + expression: { + op: 'multiply', + left: 0.6, + right: { + op: 'add', + left: 1, + right: { + op: 'divide', + left: { + ref: { + source: 'equipment', + path: 'protocolTalentUpgrades', + }, + }, + right: 5, + }, + }, + }, + }, + }, + }, + ], + }, + ], + }; + const result = parseConditionalMechanicDraft(happy, { + variantKeys: ['base'], + programName: 'Happy Survival', + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.programs.base.clauses[0].outcomes).toEqual([ + expect.objectContaining({ + kind: 'resource_cost', + resource: 'health', + value: 2, + }), + expect.objectContaining({ + kind: 'direct_damage', + combatTiming: 'current', + value: expect.objectContaining({ + right: expect.objectContaining({ + right: expect.objectContaining({ + left: { + ref: { + source: 'equipment', + path: 'protocolTalentUpgrades', + }, + }, + }), + }), + }), + }), + ]); + }); - it('accepts catalog Attribute references and rejects unknown IDs recursively', () => { - const referenced = draft(); - referenced.clauses[0].outcomes[0] = { - key: 'health-restoration', - label: 'Health Restoration', - phase: 'after_event', - kind: 'restoration', - resource: 'health', - scope: 'self', - basis: 'flat', - delivery: 'continuous', - durationSeconds: 3, - variants: { - I: { - value: 240, - expression: { - op: 'multiply', - left: 240, - right: { - op: 'divide', - left: { - ref: { - source: 'attribute', - attributeId: 'firepower-multiplier' - } - }, - right: 100 - } - } - }, - II: { - value: 336, - expression: { - op: 'multiply', - left: 336, - right: { - op: 'divide', - left: { - ref: { - source: 'attribute', - attributeId: 'firepower-multiplier' - } - }, - right: 100 - } - } - } - } - }; - const accepted = parseConditionalMechanicDraft(referenced, { - variantKeys: ['I', 'II'], - allowedAttributeIds: new Set(['firepower-multiplier']) - }); - expect(accepted.ok).toBe(true); - if (accepted.ok) { - expect(accepted.value.programs.I.clauses[0].outcomes?.[0].value).toMatchObject({ - right: { - left: { - ref: { - source: 'attribute', - attributeId: 'firepower-multiplier' - } - } - } - }); - expect(accepted.value.programs.II.clauses[0].outcomes?.[0]).toMatchObject({ - delivery: 'continuous', - durationSeconds: 3, - value: { left: 336 } - }); - } + it('accepts catalog Attribute references and rejects unknown IDs recursively', () => { + const referenced = draft(); + referenced.clauses[0].outcomes[0] = { + key: 'health-restoration', + label: 'Health Restoration', + phase: 'after_event', + kind: 'restoration', + resource: 'health', + scope: 'self', + basis: 'flat', + delivery: 'continuous', + durationSeconds: 3, + variants: { + I: { + value: 240, + expression: { + op: 'multiply', + left: 240, + right: { + op: 'divide', + left: { + ref: { + source: 'attribute', + attributeId: 'firepower-multiplier', + }, + }, + right: 100, + }, + }, + }, + II: { + value: 336, + expression: { + op: 'multiply', + left: 336, + right: { + op: 'divide', + left: { + ref: { + source: 'attribute', + attributeId: 'firepower-multiplier', + }, + }, + right: 100, + }, + }, + }, + }, + }; + const accepted = parseConditionalMechanicDraft(referenced, { + variantKeys: ['I', 'II'], + allowedAttributeIds: new Set(['firepower-multiplier']), + }); + expect(accepted.ok).toBe(true); + if (accepted.ok) { + expect( + accepted.value.programs.I.clauses[0].outcomes?.[0].value, + ).toMatchObject({ + right: { + left: { + ref: { + source: 'attribute', + attributeId: 'firepower-multiplier', + }, + }, + }, + }); + expect(accepted.value.programs.II.clauses[0].outcomes?.[0]).toMatchObject( + { + delivery: 'continuous', + durationSeconds: 3, + value: { left: 336 }, + }, + ); + } - const rejected = parseConditionalMechanicDraft(referenced, { - variantKeys: ['I', 'II'], - allowedAttributeIds: new Set(['weapon-damage']) - }); - expect(rejected.ok).toBe(false); - if (!rejected.ok) { - expect(rejected.errors).toContain( - 'Conditional mechanic clauses item 1 outcomes item 1 variants I expression right left ref references unknown Attribute "firepower-multiplier".' - ); - } - }); + const rejected = parseConditionalMechanicDraft(referenced, { + variantKeys: ['I', 'II'], + allowedAttributeIds: new Set(['weapon-damage']), + }); + expect(rejected.ok).toBe(false); + if (!rejected.ok) { + expect(rejected.errors).toContain( + 'Conditional mechanic clauses item 1 outcomes item 1 variants I expression right left ref references unknown Attribute "firepower-multiplier".', + ); + } + }); - it('preserves target entity mutations and state-scaled exit outcomes', () => { - const targeted: ConditionalMechanicDraftV1 = { - version: 1, - simulationReadiness: 'ready', - scenarioInputs: [], - pools: [{ - key: 'marked-target', - label: 'Marked Target', - stateKind: 'target_mark', - scope: 'target', - initial: 0, - min: 0, - max: 1 - }], - clauses: [{ - key: 'detonate-mark', - event: 'enemy_killed', - reducer: { kind: 'none' }, - mutations: [{ - kind: 'set', - pool: 'marked-target', - entity: { kind: 'event_target' }, - value: 1 - }], - outcomes: [{ - key: 'target-damage', - label: 'Target Damage', - phase: 'on_exit', - kind: 'direct_damage', - scope: 'target', - basis: 'flat', - statePool: 'marked-target', - perStack: true, - variants: { - base: { - value: 240, - expression: { - op: 'multiply', - left: 240, - right: { - op: 'divide', - left: { - ref: { - source: 'attribute', - attributeId: 'firepower-multiplier' - } - }, - right: 100 - } - } - } - } - }] - }] - }; - const result = parseConditionalMechanicDraft(targeted, { - variantKeys: ['base'], - allowedAttributeIds: new Set(['firepower-multiplier']) - }); + it('preserves target entity mutations and state-scaled exit outcomes', () => { + const targeted: ConditionalMechanicDraftV1 = { + version: 1, + simulationReadiness: 'ready', + scenarioInputs: [], + pools: [ + { + key: 'marked-target', + label: 'Marked Target', + stateKind: 'target_mark', + scope: 'target', + initial: 0, + min: 0, + max: 1, + }, + ], + clauses: [ + { + key: 'detonate-mark', + event: 'enemy_killed', + reducer: { kind: 'none' }, + mutations: [ + { + kind: 'set', + pool: 'marked-target', + entity: { kind: 'event_target' }, + value: 1, + }, + ], + outcomes: [ + { + key: 'target-damage', + label: 'Target Damage', + phase: 'on_exit', + kind: 'direct_damage', + scope: 'target', + basis: 'flat', + statePool: 'marked-target', + perStack: true, + variants: { + base: { + value: 240, + expression: { + op: 'multiply', + left: 240, + right: { + op: 'divide', + left: { + ref: { + source: 'attribute', + attributeId: 'firepower-multiplier', + }, + }, + right: 100, + }, + }, + }, + }, + }, + ], + }, + ], + }; + const result = parseConditionalMechanicDraft(targeted, { + variantKeys: ['base'], + allowedAttributeIds: new Set(['firepower-multiplier']), + }); - expect(result.ok).toBe(true); - if (!result.ok) return; - expect(result.value.draft).toMatchObject(targeted); - expect(result.value.programs.base).toMatchObject({ - pools: [{ key: 'marked-target', scope: 'target' }], - clauses: [{ - mutations: [{ - kind: 'set', - pool: 'marked-target', - entity: { kind: 'event_target' }, - value: 1 - }], - outcomes: [{ - phase: 'on_exit', - kind: 'direct_damage', - statePool: 'marked-target', - perStack: true, - value: { - right: { - left: { - ref: { - source: 'attribute', - attributeId: 'firepower-multiplier' - } - } - } - } - }] - }] - }); - }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.draft).toMatchObject(targeted); + expect(result.value.programs.base).toMatchObject({ + pools: [{ key: 'marked-target', scope: 'target' }], + clauses: [ + { + mutations: [ + { + kind: 'set', + pool: 'marked-target', + entity: { kind: 'event_target' }, + value: 1, + }, + ], + outcomes: [ + { + phase: 'on_exit', + kind: 'direct_damage', + statePool: 'marked-target', + perStack: true, + value: { + right: { + left: { + ref: { + source: 'attribute', + attributeId: 'firepower-multiplier', + }, + }, + }, + }, + }, + ], + }, + ], + }); + }); - it('rejects outcome fields that are absent from the canonical capability manifest', () => { - const unsupported = draft(); - (unsupported.clauses[0].outcomes[0] as unknown as Record).cadence = - 'continuous'; - const result = parseConditionalMechanicDraft(unsupported, { - variantKeys: ['I', 'II'], - allowedAttributeIds: new Set(['weapon-damage']) - }); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.errors).toContain( - 'Clause 1 outcome 1 contains unsupported field "cadence".' - ); - } - }); + it('rejects outcome fields that are absent from the canonical capability manifest', () => { + const unsupported = draft(); + ( + unsupported.clauses[0].outcomes[0] as unknown as Record + ).cadence = 'continuous'; + const result = parseConditionalMechanicDraft(unsupported, { + variantKeys: ['I', 'II'], + allowedAttributeIds: new Set(['weapon-damage']), + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors).toContain( + 'Clause 1 outcome 1 contains unsupported field "cadence".', + ); + } + }); - it('requires the exact configured variants and returns validation errors without throwing', () => { - const invalid = draft(); - invalid.clauses[0].outcomes[0].variants = { - I: { value: 3 }, - legacy: { value: 99 } - }; - const result = parseConditionalMechanicDraft(invalid, { variantKeys: ['I', 'II'] }); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.errors).toContain( - 'Clause 1 outcome 1 must define exactly these variants: I, II.' - ); - } - expect(() => parseConditionalMechanicDraft('{not-json', { variantKeys: ['base'] })) - .not.toThrow(); - expect(parseConditionalMechanicDraft('{not-json', { variantKeys: ['base'] })) - .toEqual({ - ok: false, - errors: ['The conditional mechanic contains malformed JSON.'] - }); - }); + it('requires the exact configured variants and returns validation errors without throwing', () => { + const invalid = draft(); + invalid.clauses[0].outcomes[0].variants = { + I: { value: 3 }, + legacy: { value: 99 }, + }; + const result = parseConditionalMechanicDraft(invalid, { + variantKeys: ['I', 'II'], + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors).toContain( + 'Clause 1 outcome 1 must define exactly these variants: I, II.', + ); + } + expect(() => + parseConditionalMechanicDraft('{not-json', { variantKeys: ['base'] }), + ).not.toThrow(); + expect( + parseConditionalMechanicDraft('{not-json', { variantKeys: ['base'] }), + ).toEqual({ + ok: false, + errors: ['The conditional mechanic contains malformed JSON.'], + }); + }); - it('rejects invalid pre-event timing for current Health percentage references', () => { - const invalid = draft(); - invalid.clauses[0].if = { - kind: 'compare', - left: { source: 'combat', path: 'currentHealthPercent', timing: 'pre_event' }, - operator: 'lt', - right: 50 - }; - const result = parseConditionalMechanicDraft(invalid, { - variantKeys: ['I', 'II'], - allowedAttributeIds: new Set(['weapon-damage']) - }); - expect(result.ok).toBe(false); - }); + it('rejects invalid pre-event timing for current Health percentage references', () => { + const invalid = draft(); + invalid.clauses[0].if = { + kind: 'compare', + left: { + source: 'combat', + path: 'currentHealthPercent', + timing: 'pre_event', + }, + operator: 'lt', + right: 50, + }; + const result = parseConditionalMechanicDraft(invalid, { + variantKeys: ['I', 'II'], + allowedAttributeIds: new Set(['weapon-damage']), + }); + expect(result.ok).toBe(false); + }); - it('accepts compatible radius targeting and rejects incompatible scopes', () => { - const valid = draft(); - valid.clauses[0].outcomes[0].scope = 'allies'; - valid.clauses[0].outcomes[0].targeting = { - kind: 'within_radius', - population: 'allies', - radiusMeters: 40 - }; - const parsed = parseConditionalMechanicDraft(valid, { - variantKeys: ['I', 'II'], - allowedAttributeIds: new Set(['weapon-damage']) - }); - expect(parsed.ok).toBe(true); - if (parsed.ok) { - expect(parsed.value.draft.clauses[0].outcomes[0].targeting).toEqual( - valid.clauses[0].outcomes[0].targeting - ); - } + it('accepts compatible radius targeting and rejects incompatible scopes', () => { + const valid = draft(); + valid.clauses[0].outcomes[0].scope = 'allies'; + valid.clauses[0].outcomes[0].targeting = { + kind: 'within_radius', + population: 'allies', + radiusMeters: 40, + }; + const parsed = parseConditionalMechanicDraft(valid, { + variantKeys: ['I', 'II'], + allowedAttributeIds: new Set(['weapon-damage']), + }); + expect(parsed.ok).toBe(true); + if (parsed.ok) { + expect(parsed.value.draft.clauses[0].outcomes[0].targeting).toEqual( + valid.clauses[0].outcomes[0].targeting, + ); + } - valid.clauses[0].outcomes[0].scope = 'self'; - const incompatible = parseConditionalMechanicDraft(valid, { - variantKeys: ['I', 'II'], - allowedAttributeIds: new Set(['weapon-damage']) - }); - expect(incompatible.ok).toBe(false); - if (!incompatible.ok) { - expect(incompatible.errors).toContain( - 'Clause 1 outcome 1 ally radius targeting requires allies or self-and-allies scope.' - ); - } - }); + valid.clauses[0].outcomes[0].scope = 'self'; + const incompatible = parseConditionalMechanicDraft(valid, { + variantKeys: ['I', 'II'], + allowedAttributeIds: new Set(['weapon-damage']), + }); + expect(incompatible.ok).toBe(false); + if (!incompatible.ok) { + expect(incompatible.errors).toContain( + 'Clause 1 outcome 1 ally radius targeting requires allies or self-and-allies scope.', + ); + } + }); }); diff --git a/src/lib/calculations/mechanic-editor.ts b/src/lib/calculations/mechanic-editor.ts index 776475a..f5b49f8 100644 --- a/src/lib/calculations/mechanic-editor.ts +++ b/src/lib/calculations/mechanic-editor.ts @@ -1,773 +1,924 @@ import { - MECHANIC_EVENT_KINDS, - MECHANIC_EVENT_REGISTRY, - validateMechanicProgram, - type ClauseMutation, - type MechanicClause, - type MechanicEventKind, - type MechanicOutcome, - type MechanicOutcomeScope, - type MechanicProgram, - type MechanicResource, - type NumericExpression, - type OutcomeBasis, - type OutcomePhase, - type PoolDefinition, - type Predicate, - type Reducer, - type Scalar, - type ScenarioInputDefinition, - type StateScope, - type ValueReference + MECHANIC_EVENT_KINDS, + MECHANIC_EVENT_REGISTRY, + validateMechanicProgram, + type ClauseMutation, + type MechanicClause, + type MechanicEventKind, + type MechanicOutcome, + type MechanicOutcomeScope, + type MechanicProgram, + type MechanicResource, + type NumericExpression, + type OutcomeBasis, + type OutcomePhase, + type PoolDefinition, + type Predicate, + type Reducer, + type Scalar, + type ScenarioInputDefinition, + type StateScope, + type ValueReference, } from './mechanic.js'; export const CONDITIONAL_MECHANIC_DRAFT_VERSION = 1 as const; export type MechanicStateKind = - | 'flag' - | 'count' - | 'stack' - | 'duration' - | 'scalar' - | 'target_mark' - | 'resource'; + | 'flag' + | 'count' + | 'stack' + | 'duration' + | 'scalar' + | 'target_mark' + | 'resource'; export type ConditionalMechanicPoolDraft = PoolDefinition & { - stateKind: MechanicStateKind; - expiresAfterSecondsVariants?: Record; + stateKind: MechanicStateKind; + expiresAfterSecondsVariants?: Record; }; export type ConditionalMechanicVariantValue = { - value: number; - expression?: NumericExpression; + value: number; + expression?: NumericExpression; }; -export type ConditionalMechanicOutcomeDraft = Omit & { - key: string; - descriptionSlot?: number; - variants: Record; +export type ConditionalMechanicOutcomeDraft = Omit< + MechanicOutcome, + 'id' | 'value' +> & { + key: string; + descriptionSlot?: number; + variants: Record; }; -export type ConditionalMechanicClauseDraft = Omit & { - key: string; - event: MechanicEventKind; - recipeKey?: string; - outcomes: ConditionalMechanicOutcomeDraft[]; +export type ConditionalMechanicClauseDraft = Omit< + MechanicClause, + 'id' | 'when' | 'outcomes' +> & { + key: string; + event: MechanicEventKind; + recipeKey?: string; + outcomes: ConditionalMechanicOutcomeDraft[]; }; export type ConditionalMechanicDraftV1 = { - version: typeof CONDITIONAL_MECHANIC_DRAFT_VERSION; - simulationReadiness: 'ready' | 'requires_assumptions' | 'captured_only'; - scenarioInputs: ScenarioInputDefinition[]; - pools: ConditionalMechanicPoolDraft[]; - clauses: ConditionalMechanicClauseDraft[]; + version: typeof CONDITIONAL_MECHANIC_DRAFT_VERSION; + simulationReadiness: 'ready' | 'requires_assumptions' | 'captured_only'; + scenarioInputs: ScenarioInputDefinition[]; + pools: ConditionalMechanicPoolDraft[]; + clauses: ConditionalMechanicClauseDraft[]; }; export type ParseConditionalMechanicDraftOptions = { - variantKeys: readonly string[]; - programId?: string; - programName?: string; - allowedAttributeIds?: ReadonlySet; - allowedRecipeKeys?: ReadonlySet; + variantKeys: readonly string[]; + programId?: string; + programName?: string; + allowedAttributeIds?: ReadonlySet; + allowedRecipeKeys?: ReadonlySet; }; export type ParsedConditionalMechanicDraft = { - draft: ConditionalMechanicDraftV1; - programs: Record; + draft: ConditionalMechanicDraftV1; + programs: Record; }; export type ConditionalMechanicDraftResult = - | { ok: true; value: ParsedConditionalMechanicDraft } - | { ok: false; errors: string[] }; + | { ok: true; value: ParsedConditionalMechanicDraft } + | { ok: false; errors: string[] }; export type CurrentHealthPercentBand = { - minimum?: number; - maximum: number; + minimum?: number; + maximum: number; }; const currentHealthPercentReference = { - source: 'combat', - path: 'currentHealthPercent', - timing: 'current' + source: 'combat', + path: 'currentHealthPercent', + timing: 'current', } as const; export function currentHealthPercentBandPredicate( - band: CurrentHealthPercentBand + band: CurrentHealthPercentBand, ): Predicate { - const maximum: Predicate = { - kind: 'compare', - left: currentHealthPercentReference, - operator: 'lt', - right: band.maximum - }; - if (band.minimum === undefined) return maximum; - return { - kind: 'all', - predicates: [{ - kind: 'compare', - left: currentHealthPercentReference, - operator: 'gte', - right: band.minimum - }, maximum] - }; + const maximum: Predicate = { + kind: 'compare', + left: currentHealthPercentReference, + operator: 'lt', + right: band.maximum, + }; + if (band.minimum === undefined) return maximum; + return { + kind: 'all', + predicates: [ + { + kind: 'compare', + left: currentHealthPercentReference, + operator: 'gte', + right: band.minimum, + }, + maximum, + ], + }; } export function readCurrentHealthPercentBand( - predicate: Predicate | undefined + predicate: Predicate | undefined, ): CurrentHealthPercentBand | null { - const predicates = predicate?.kind === 'all' ? predicate.predicates : predicate ? [predicate] : []; - let minimum: number | undefined; - let maximum: number | undefined; - for (const item of predicates) { - if ( - item.kind !== 'compare' - || typeof item.left !== 'object' - || item.left === null - || item.left.source !== 'combat' - || item.left.path !== 'currentHealthPercent' - || item.left.timing !== 'current' - || typeof item.right !== 'number' - ) return null; - if (item.operator === 'gte') minimum = item.right; - else if (item.operator === 'lt') maximum = item.right; - else return null; - } - return maximum === undefined ? null : { ...(minimum === undefined ? {} : { minimum }), maximum }; + const predicates = + predicate?.kind === 'all' + ? predicate.predicates + : predicate + ? [predicate] + : []; + let minimum: number | undefined; + let maximum: number | undefined; + for (const item of predicates) { + if ( + item.kind !== 'compare' || + typeof item.left !== 'object' || + item.left === null || + item.left.source !== 'combat' || + item.left.path !== 'currentHealthPercent' || + item.left.timing !== 'current' || + typeof item.right !== 'number' + ) + return null; + if (item.operator === 'gte') minimum = item.right; + else if (item.operator === 'lt') maximum = item.right; + else return null; + } + return maximum === undefined + ? null + : { ...(minimum === undefined ? {} : { minimum }), maximum }; } -export type PredicateComparisonOperator = 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte'; +export type PredicateComparisonOperator = + 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte'; export type EditablePredicateScalar = Exclude; export type EventFieldComparison = { - path: string; - operator: PredicateComparisonOperator; - value: EditablePredicateScalar; + path: string; + operator: PredicateComparisonOperator; + value: EditablePredicateScalar; }; export function eventFieldComparisonPredicate( - comparison: EventFieldComparison + comparison: EventFieldComparison, ): Predicate { - return { - kind: 'compare', - left: { source: 'event', path: comparison.path }, - operator: comparison.operator, - right: comparison.value - }; + return { + kind: 'compare', + left: { source: 'event', path: comparison.path }, + operator: comparison.operator, + right: comparison.value, + }; } export function readEventFieldComparison( - predicate: Predicate | undefined + predicate: Predicate | undefined, ): EventFieldComparison | null { - if ( - predicate?.kind !== 'compare' - || typeof predicate.left !== 'object' - || predicate.left === null - || predicate.left.source !== 'event' - || !predicate.left.path.trim() - || (typeof predicate.right !== 'string' - && typeof predicate.right !== 'number' - && typeof predicate.right !== 'boolean') - ) return null; - return { - path: predicate.left.path, - operator: predicate.operator, - value: predicate.right - }; + if ( + predicate?.kind !== 'compare' || + typeof predicate.left !== 'object' || + predicate.left === null || + predicate.left.source !== 'event' || + !predicate.left.path.trim() || + (typeof predicate.right !== 'string' && + typeof predicate.right !== 'number' && + typeof predicate.right !== 'boolean') + ) + return null; + return { + path: predicate.left.path, + operator: predicate.operator, + value: predicate.right, + }; } export function registeredEventFields(event: MechanicEventKind) { - return MECHANIC_EVENT_REGISTRY[event].fields ?? []; + return MECHANIC_EVENT_REGISTRY[event].fields ?? []; } export type StatePoolThresholdComparison = { - pool: string; - operator: PredicateComparisonOperator; - value: number; + pool: string; + operator: PredicateComparisonOperator; + value: number; }; export function statePoolThresholdPredicate( - comparison: StatePoolThresholdComparison + comparison: StatePoolThresholdComparison, ): Predicate { - return { - kind: 'compare', - left: { source: 'pool', pool: comparison.pool }, - operator: comparison.operator, - right: comparison.value - }; + return { + kind: 'compare', + left: { source: 'pool', pool: comparison.pool }, + operator: comparison.operator, + right: comparison.value, + }; } export function readStatePoolThreshold( - predicate: Predicate | undefined + predicate: Predicate | undefined, ): StatePoolThresholdComparison | null { - if ( - predicate?.kind !== 'compare' - || typeof predicate.left !== 'object' - || predicate.left === null - || predicate.left.source !== 'pool' - || !predicate.left.pool.trim() - || predicate.left.entity !== undefined - || typeof predicate.right !== 'number' - || !Number.isFinite(predicate.right) - ) return null; - return { - pool: predicate.left.pool, - operator: predicate.operator, - value: predicate.right - }; + if ( + predicate?.kind !== 'compare' || + typeof predicate.left !== 'object' || + predicate.left === null || + predicate.left.source !== 'pool' || + !predicate.left.pool.trim() || + predicate.left.entity !== undefined || + typeof predicate.right !== 'number' || + !Number.isFinite(predicate.right) + ) + return null; + return { + pool: predicate.left.pool, + operator: predicate.operator, + value: predicate.right, + }; } const object = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value); + typeof value === 'object' && value !== null && !Array.isArray(value); const finite = (value: unknown): value is number => - typeof value === 'number' && Number.isFinite(value); + typeof value === 'number' && Number.isFinite(value); const scalar = (value: unknown) => - value === null || ['string', 'number', 'boolean'].includes(typeof value); + value === null || ['string', 'number', 'boolean'].includes(typeof value); const identifier = /^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/; const events = new Set(MECHANIC_EVENT_KINDS); export const CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES = { - required: ['key', 'label', 'phase', 'kind', 'scope', 'basis', 'variants'], - optional: [ - 'resource', - 'delivery', - 'durationSeconds', - 'combatTiming', - 'attributeId', - 'statePool', - 'perStack', - 'minStacks', - 'maxStacks', - 'targeting', - 'descriptionSlot' - ], - phase: ['on_event', 'after_event', 'on_enter', 'on_exit', 'while_active'], - kind: ['attribute_modifier', 'direct_damage', 'restoration', 'resource_cost', 'weapon_reload', 'marker'], - scope: [ - 'self', - 'equipped_weapon', - 'inactive_weapon', - 'target', - 'allies', - 'self_and_allies', - 'assigned_ability', - 'all_skills' - ], - basis: ['flat', 'max_health_percent', 'current_health_percent', 'weapon_base_percent'], - resource: ['health', 'armor', 'ammo', 'signature_ability_charge'], - delivery: ['instant', 'continuous'], - deliverySemantics: { - instant: 'The full resolved value applies at activation.', - continuous: - 'Valid only for restoration. The value is the total restoration, delivered at a constant rate across durationSeconds. Each activation creates an independent stream; overlapping streams add and overheal is lost when it occurs.' - } + required: ['key', 'label', 'phase', 'kind', 'scope', 'basis', 'variants'], + optional: [ + 'resource', + 'delivery', + 'durationSeconds', + 'combatTiming', + 'attributeId', + 'statePool', + 'perStack', + 'minStacks', + 'maxStacks', + 'targeting', + 'descriptionSlot', + ], + phase: ['on_event', 'after_event', 'on_enter', 'on_exit', 'while_active'], + kind: [ + 'attribute_modifier', + 'direct_damage', + 'restoration', + 'resource_cost', + 'weapon_reload', + 'marker', + ], + scope: [ + 'self', + 'equipped_weapon', + 'inactive_weapon', + 'target', + 'allies', + 'self_and_allies', + 'assigned_ability', + 'all_skills', + ], + basis: [ + 'flat', + 'max_health_percent', + 'current_health_percent', + 'weapon_base_percent', + ], + resource: ['health', 'armor', 'ammo', 'signature_ability_charge'], + delivery: ['instant', 'continuous'], + deliverySemantics: { + instant: 'The full resolved value applies at activation.', + continuous: + 'Valid only for restoration. The value is the total restoration, delivered at a constant rate across durationSeconds. Each activation creates an independent stream; overlapping streams add and overheal is lost when it occurs.', + }, } as const; -const phases = new Set(CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES.phase); +const phases = new Set( + CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES.phase, +); const outcomeKinds = new Set( - CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES.kind + CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES.kind, +); +const scopes = new Set( + CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES.scope, +); +const bases = new Set( + CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES.basis, +); +const resources = new Set( + CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES.resource, +); +const deliveryModes = new Set( + CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES.delivery, ); -const scopes = new Set(CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES.scope); -const bases = new Set(CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES.basis); -const resources = new Set(CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES.resource); -const deliveryModes = new Set(CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES.delivery); const outcomeFields = new Set([ - ...CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES.required, - ...CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES.optional + ...CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES.required, + ...CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES.optional, ]); const stateKinds = new Set([ - 'flag', - 'count', - 'stack', - 'duration', - 'scalar', - 'target_mark', - 'resource' + 'flag', + 'count', + 'stack', + 'duration', + 'scalar', + 'target_mark', + 'resource', ]); function parseRaw(raw: unknown, errors: string[]) { - if (typeof raw !== 'string') return raw; - try { - return JSON.parse(raw); - } catch { - errors.push('The conditional mechanic contains malformed JSON.'); - return null; - } + if (typeof raw !== 'string') return raw; + try { + return JSON.parse(raw); + } catch { + errors.push('The conditional mechanic contains malformed JSON.'); + return null; + } } function key(value: unknown, path: string, errors: string[]) { - if (typeof value !== 'string' || !identifier.test(value)) { - errors.push(`${path} must use a stable lower-case key.`); - return ''; - } - return value; + if (typeof value !== 'string' || !identifier.test(value)) { + errors.push(`${path} must use a stable lower-case key.`); + return ''; + } + return value; } function isEntitySelector(value: unknown) { - if (!object(value) || typeof value.kind !== 'string') return false; - if (['agent', 'event_actor', 'event_target'].includes(value.kind)) return true; - return value.kind === 'event_field' && typeof value.path === 'string' && Boolean(value.path.trim()); + if (!object(value) || typeof value.kind !== 'string') return false; + if (['agent', 'event_actor', 'event_target'].includes(value.kind)) + return true; + return ( + value.kind === 'event_field' && + typeof value.path === 'string' && + Boolean(value.path.trim()) + ); } function isReference(value: unknown): value is ValueReference { - if (!object(value) || typeof value.source !== 'string') return false; - switch (value.source) { - case 'event': - return typeof value.path === 'string' && Boolean(value.path.trim()); - case 'scenario': - return typeof value.key === 'string' && Boolean(value.key.trim()); - case 'combat': - return typeof value.path === 'string' - && Boolean(value.path.trim()) - && (value.timing === undefined || value.timing === 'pre_event' || value.timing === 'current') - && !(value.path === 'currentHealthPercent' && value.timing === 'pre_event'); - case 'equipment': - return typeof value.path === 'string' && Boolean(value.path.trim()); - case 'attribute': - return typeof value.attributeId === 'string' && Boolean(value.attributeId.trim()); - case 'reducer': - return true; - case 'pool': - return typeof value.pool === 'string' - && Boolean(value.pool.trim()) - && (value.entity === undefined || isEntitySelector(value.entity)); - default: - return false; - } + if (!object(value) || typeof value.source !== 'string') return false; + switch (value.source) { + case 'event': + return typeof value.path === 'string' && Boolean(value.path.trim()); + case 'scenario': + return typeof value.key === 'string' && Boolean(value.key.trim()); + case 'combat': + return ( + typeof value.path === 'string' && + Boolean(value.path.trim()) && + (value.timing === undefined || + value.timing === 'pre_event' || + value.timing === 'current') && + !(value.path === 'currentHealthPercent' && value.timing === 'pre_event') + ); + case 'equipment': + return typeof value.path === 'string' && Boolean(value.path.trim()); + case 'attribute': + return ( + typeof value.attributeId === 'string' && + Boolean(value.attributeId.trim()) + ); + case 'reducer': + return true; + case 'pool': + return ( + typeof value.pool === 'string' && + Boolean(value.pool.trim()) && + (value.entity === undefined || isEntitySelector(value.entity)) + ); + default: + return false; + } } function validateAttributeReferences( - value: unknown, - allowedAttributeIds: ReadonlySet | undefined, - errors: string[], - path = 'Conditional mechanic' + value: unknown, + allowedAttributeIds: ReadonlySet | undefined, + errors: string[], + path = 'Conditional mechanic', ) { - if (Array.isArray(value)) { - value.forEach((item, index) => - validateAttributeReferences(item, allowedAttributeIds, errors, `${path} item ${index + 1}`)); - return; - } - if (!object(value)) return; - if ( - value.source === 'attribute' - && typeof value.attributeId === 'string' - && value.attributeId.trim() - && allowedAttributeIds - && !allowedAttributeIds.has(value.attributeId) - ) { - errors.push(`${path} references unknown Attribute "${value.attributeId}".`); - return; - } - for (const [key, nested] of Object.entries(value)) { - validateAttributeReferences(nested, allowedAttributeIds, errors, `${path} ${key}`); - } + if (Array.isArray(value)) { + value.forEach((item, index) => + validateAttributeReferences( + item, + allowedAttributeIds, + errors, + `${path} item ${index + 1}`, + ), + ); + return; + } + if (!object(value)) return; + if ( + value.source === 'attribute' && + typeof value.attributeId === 'string' && + value.attributeId.trim() && + allowedAttributeIds && + !allowedAttributeIds.has(value.attributeId) + ) { + errors.push(`${path} references unknown Attribute "${value.attributeId}".`); + return; + } + for (const [key, nested] of Object.entries(value)) { + validateAttributeReferences( + nested, + allowedAttributeIds, + errors, + `${path} ${key}`, + ); + } } function isExpression(value: unknown): value is NumericExpression { - if (finite(value)) return true; - if (!object(value)) return false; - if ('ref' in value) return isReference(value.ref); - if (value.op === 'floor') return isExpression(value.value); - if (value.op === 'clamp') { - return isExpression(value.value) && isExpression(value.min) && isExpression(value.max); - } - return ['add', 'subtract', 'multiply', 'divide', 'min', 'max'].includes(String(value.op)) - && isExpression(value.left) - && isExpression(value.right); + if (finite(value)) return true; + if (!object(value)) return false; + if ('ref' in value) return isReference(value.ref); + if (value.op === 'floor') return isExpression(value.value); + if (value.op === 'clamp') { + return ( + isExpression(value.value) && + isExpression(value.min) && + isExpression(value.max) + ); + } + return ( + ['add', 'subtract', 'multiply', 'divide', 'min', 'max'].includes( + String(value.op), + ) && + isExpression(value.left) && + isExpression(value.right) + ); } function isOperand(value: unknown) { - return scalar(value) || isReference(value); + return scalar(value) || isReference(value); } function isPredicate(value: unknown): value is Predicate { - if (!object(value) || typeof value.kind !== 'string') return false; - if (value.kind === 'true') return true; - if (value.kind === 'exists') return isReference(value.value); - if (value.kind === 'not') return isPredicate(value.predicate); - if (value.kind === 'all' || value.kind === 'any') { - return Array.isArray(value.predicates) && value.predicates.every(isPredicate); - } - return value.kind === 'compare' - && ['eq', 'ne', 'gt', 'gte', 'lt', 'lte'].includes(String(value.operator)) - && isOperand(value.left) - && isOperand(value.right); + if (!object(value) || typeof value.kind !== 'string') return false; + if (value.kind === 'true') return true; + if (value.kind === 'exists') return isReference(value.value); + if (value.kind === 'not') return isPredicate(value.predicate); + if (value.kind === 'all' || value.kind === 'any') { + return ( + Array.isArray(value.predicates) && value.predicates.every(isPredicate) + ); + } + return ( + value.kind === 'compare' && + ['eq', 'ne', 'gt', 'gte', 'lt', 'lte'].includes(String(value.operator)) && + isOperand(value.left) && + isOperand(value.right) + ); } function optionalGroupBy(value: Record) { - return value.groupBy === undefined || isReference(value.groupBy); + return value.groupBy === undefined || isReference(value.groupBy); } function isReducer(value: unknown): value is Reducer { - if (!object(value) || typeof value.kind !== 'string') return false; - switch (value.kind) { - case 'none': - return true; - case 'count': - return finite(value.threshold) && optionalGroupBy(value); - case 'modulo': - return finite(value.every) && optionalGroupBy(value); - case 'distinct_count': - return finite(value.threshold) - && finite(value.windowSeconds) - && isReference(value.distinctBy); - case 'consecutive_count': - return finite(value.threshold) && optionalGroupBy(value); - case 'sliding_count': - return finite(value.threshold) && finite(value.windowSeconds) && optionalGroupBy(value); - case 'duration': - return finite(value.seconds) && optionalGroupBy(value); - case 'periodic': - return isExpression(value.intervalSeconds) && optionalGroupBy(value); - case 'derived_scalar': - return isExpression(value.value); - default: - return false; - } + if (!object(value) || typeof value.kind !== 'string') return false; + switch (value.kind) { + case 'none': + return true; + case 'count': + return finite(value.threshold) && optionalGroupBy(value); + case 'modulo': + return finite(value.every) && optionalGroupBy(value); + case 'distinct_count': + return ( + finite(value.threshold) && + finite(value.windowSeconds) && + isReference(value.distinctBy) + ); + case 'consecutive_count': + return finite(value.threshold) && optionalGroupBy(value); + case 'sliding_count': + return ( + finite(value.threshold) && + finite(value.windowSeconds) && + optionalGroupBy(value) + ); + case 'duration': + return finite(value.seconds) && optionalGroupBy(value); + case 'periodic': + return isExpression(value.intervalSeconds) && optionalGroupBy(value); + case 'derived_scalar': + return isExpression(value.value); + default: + return false; + } } function isMutation(value: unknown): value is ClauseMutation { - if (!object(value) || typeof value.kind !== 'string') return false; - if (value.kind === 'none') return true; - if (value.kind === 'clear') { - return typeof value.pool === 'string' - && (value.entity === undefined || isEntitySelector(value.entity)); - } - if (value.kind === 'transfer') { - return typeof value.pool === 'string' - && isEntitySelector(value.from) - && isEntitySelector(value.to) - && (value.value === undefined || value.value === 'all' || isExpression(value.value)); - } - return ['add', 'set', 'consume', 'derive'].includes(value.kind) - && typeof value.pool === 'string' - && isExpression(value.value) - && (value.entity === undefined || isEntitySelector(value.entity)); + if (!object(value) || typeof value.kind !== 'string') return false; + if (value.kind === 'none') return true; + if (value.kind === 'clear') { + return ( + typeof value.pool === 'string' && + (value.entity === undefined || isEntitySelector(value.entity)) + ); + } + if (value.kind === 'transfer') { + return ( + typeof value.pool === 'string' && + isEntitySelector(value.from) && + isEntitySelector(value.to) && + (value.value === undefined || + value.value === 'all' || + isExpression(value.value)) + ); + } + return ( + ['add', 'set', 'consume', 'derive'].includes(value.kind) && + typeof value.pool === 'string' && + isExpression(value.value) && + (value.entity === undefined || isEntitySelector(value.entity)) + ); } function parseScenarioInputs(value: unknown, errors: string[]) { - if (!Array.isArray(value)) { - errors.push('Scenario inputs must be an array.'); - return [] as ScenarioInputDefinition[]; - } - const inputs: ScenarioInputDefinition[] = []; - for (const [index, candidate] of value.entries()) { - const path = `Scenario input ${index + 1}`; - if (!object(candidate)) { - errors.push(`${path} must be an object.`); - continue; - } - if ( - !key(candidate.key, `${path} key`, errors) - || typeof candidate.label !== 'string' - || !candidate.label.trim() - || !['toggle', 'slider', 'number'].includes(String(candidate.kind)) - ) { - errors.push(`${path} requires a label and supported input kind.`); - continue; - } - if ( - candidate.readiness !== undefined - && candidate.readiness !== 'ready' - && candidate.readiness !== 'requires_assumption' - ) { - errors.push(`${path} has an invalid readiness value.`); - continue; - } - inputs.push(candidate as ScenarioInputDefinition); - } - return inputs; + if (!Array.isArray(value)) { + errors.push('Scenario inputs must be an array.'); + return [] as ScenarioInputDefinition[]; + } + const inputs: ScenarioInputDefinition[] = []; + for (const [index, candidate] of value.entries()) { + const path = `Scenario input ${index + 1}`; + if (!object(candidate)) { + errors.push(`${path} must be an object.`); + continue; + } + if ( + !key(candidate.key, `${path} key`, errors) || + typeof candidate.label !== 'string' || + !candidate.label.trim() || + !['toggle', 'slider', 'number'].includes(String(candidate.kind)) + ) { + errors.push(`${path} requires a label and supported input kind.`); + continue; + } + if ( + candidate.readiness !== undefined && + candidate.readiness !== 'ready' && + candidate.readiness !== 'requires_assumption' + ) { + errors.push(`${path} has an invalid readiness value.`); + continue; + } + inputs.push(candidate as ScenarioInputDefinition); + } + return inputs; } -function parsePools(value: unknown, variantKeys: readonly string[], errors: string[]) { - if (!Array.isArray(value)) { - errors.push('State pools must be an array.'); - return [] as ConditionalMechanicPoolDraft[]; - } - const pools: ConditionalMechanicPoolDraft[] = []; - for (const [index, candidate] of value.entries()) { - const path = `State pool ${index + 1}`; - if (!object(candidate)) { - errors.push(`${path} must be an object.`); - continue; - } - const poolKey = key(candidate.key, `${path} key`, errors); - if ( - !poolKey - || typeof candidate.label !== 'string' - || !candidate.label.trim() - || !stateKinds.has(candidate.stateKind as MechanicStateKind) - || !['agent', 'target'].includes(String(candidate.scope)) - || !finite(candidate.initial) - ) { - errors.push(`${path} requires a label, state kind, scope, and finite initial value.`); - continue; - } - for (const property of ['min', 'max', 'bestCaseValue', 'expiresAfterSeconds'] as const) { - if (candidate[property] !== undefined && !finite(candidate[property])) { - errors.push(`${path} ${property} must be a finite number.`); - } - } - if (candidate.expiresAfterSeconds !== undefined && candidate.expiresAfterSecondsVariants !== undefined) { - errors.push(`${path} cannot define both scalar and variant expiration durations.`); - } - if (candidate.expiresAfterSecondsVariants !== undefined) { - if (!object(candidate.expiresAfterSecondsVariants)) { - errors.push(`${path} variant expiration durations must be an object.`); - } else { - const variantDurations = candidate.expiresAfterSecondsVariants; - const keys = Object.keys(variantDurations); - if ( - keys.length !== variantKeys.length - || keys.some((variant) => !variantKeys.includes(variant)) - || variantKeys.some((variant) => - !finite(variantDurations[variant]) - || Number(variantDurations[variant]) <= 0) - ) { - errors.push(`${path} variant expiration durations must define one positive number for each variant.`); - } - } - } - pools.push(candidate as ConditionalMechanicPoolDraft); - } - return pools; +function parsePools( + value: unknown, + variantKeys: readonly string[], + errors: string[], +) { + if (!Array.isArray(value)) { + errors.push('State pools must be an array.'); + return [] as ConditionalMechanicPoolDraft[]; + } + const pools: ConditionalMechanicPoolDraft[] = []; + for (const [index, candidate] of value.entries()) { + const path = `State pool ${index + 1}`; + if (!object(candidate)) { + errors.push(`${path} must be an object.`); + continue; + } + const poolKey = key(candidate.key, `${path} key`, errors); + if ( + !poolKey || + typeof candidate.label !== 'string' || + !candidate.label.trim() || + !stateKinds.has(candidate.stateKind as MechanicStateKind) || + !['agent', 'target'].includes(String(candidate.scope)) || + !finite(candidate.initial) + ) { + errors.push( + `${path} requires a label, state kind, scope, and finite initial value.`, + ); + continue; + } + for (const property of [ + 'min', + 'max', + 'bestCaseValue', + 'expiresAfterSeconds', + ] as const) { + if (candidate[property] !== undefined && !finite(candidate[property])) { + errors.push(`${path} ${property} must be a finite number.`); + } + } + if ( + candidate.expiresAfterSeconds !== undefined && + candidate.expiresAfterSecondsVariants !== undefined + ) { + errors.push( + `${path} cannot define both scalar and variant expiration durations.`, + ); + } + if (candidate.expiresAfterSecondsVariants !== undefined) { + if (!object(candidate.expiresAfterSecondsVariants)) { + errors.push(`${path} variant expiration durations must be an object.`); + } else { + const variantDurations = candidate.expiresAfterSecondsVariants; + const keys = Object.keys(variantDurations); + if ( + keys.length !== variantKeys.length || + keys.some((variant) => !variantKeys.includes(variant)) || + variantKeys.some( + (variant) => + !finite(variantDurations[variant]) || + Number(variantDurations[variant]) <= 0, + ) + ) { + errors.push( + `${path} variant expiration durations must define one positive number for each variant.`, + ); + } + } + } + pools.push(candidate as ConditionalMechanicPoolDraft); + } + return pools; } function parseOutcome( - value: unknown, - path: string, - variantKeys: readonly string[], - allowedAttributes: ReadonlySet | undefined, - errors: string[] + value: unknown, + path: string, + variantKeys: readonly string[], + allowedAttributes: ReadonlySet | undefined, + errors: string[], ): ConditionalMechanicOutcomeDraft | null { - if (!object(value)) { - errors.push(`${path} must be an object.`); - return null; - } - for (const property of Object.keys(value)) { - if (!outcomeFields.has(property)) { - errors.push(`${path} contains unsupported field "${property}".`); - } - } - const outcomeKey = key(value.key, `${path} key`, errors); - if ( - !outcomeKey - || typeof value.label !== 'string' - || !value.label.trim() - || !phases.has(value.phase as OutcomePhase) - || !outcomeKinds.has(value.kind as MechanicOutcome['kind']) - || !scopes.has(value.scope as MechanicOutcomeScope) - || !bases.has(value.basis as OutcomeBasis) - ) { - errors.push(`${path} requires a label, phase, kind, scope, and value basis.`); - return null; - } - const kind = value.kind as MechanicOutcome['kind']; - if (kind === 'attribute_modifier') { - if (typeof value.attributeId !== 'string' || !value.attributeId.trim()) { - errors.push(`${path} attribute modifiers require an Attribute ID.`); - } else if (allowedAttributes && !allowedAttributes.has(value.attributeId)) { - errors.push(`${path} references unknown Attribute "${value.attributeId}".`); - } - } else if (value.attributeId !== undefined) { - errors.push(`${path} may only use an Attribute ID for an attribute modifier.`); - } - if (kind === 'restoration' || kind === 'resource_cost') { - if (!resources.has(value.resource as MechanicResource)) { - errors.push(`${path} ${kind.replace('_', ' ')} requires a resource.`); - } - } else if (value.resource !== undefined) { - errors.push(`${path} may only use a resource for restoration or resource cost.`); - } - const delivery = value.delivery ?? 'instant'; - if (!deliveryModes.has(String(delivery))) { - errors.push(`${path} has an invalid delivery mode.`); - } else if (delivery === 'continuous') { - if (kind !== 'restoration') { - errors.push(`${path} may only use continuous delivery for restoration.`); - } - if (!finite(value.durationSeconds) || value.durationSeconds <= 0) { - errors.push(`${path} continuous delivery requires a positive duration.`); - } - } else if (value.durationSeconds !== undefined) { - errors.push(`${path} may only set a duration for continuous delivery.`); - } - if ( - value.combatTiming !== undefined - && value.combatTiming !== 'pre_event' - && value.combatTiming !== 'current' - ) { - errors.push(`${path} has invalid combat timing.`); - } - if ( - value.descriptionSlot !== undefined - && (!Number.isInteger(value.descriptionSlot) || Number(value.descriptionSlot) < 1) - ) { - errors.push(`${path} description slot must be a positive integer.`); - } - if (value.targeting !== undefined) { - if ( - !object(value.targeting) - || value.targeting.kind !== 'within_radius' - || !['allies', 'enemies'].includes(String(value.targeting.population)) - || !finite(value.targeting.radiusMeters) - || value.targeting.radiusMeters <= 0 - ) { - errors.push(`${path} has invalid radius targeting.`); - } else if ( - value.targeting.population === 'allies' - && value.scope !== 'allies' - && value.scope !== 'self_and_allies' - ) { - errors.push(`${path} ally radius targeting requires allies or self-and-allies scope.`); - } else if (value.targeting.population === 'enemies' && value.scope !== 'target') { - errors.push(`${path} enemy radius targeting requires target scope.`); - } - } - if (!object(value.variants)) { - errors.push(`${path} variants must be an object.`); - return null; - } - const actualKeys = Object.keys(value.variants).sort(); - const expectedKeys = [...variantKeys].sort(); - if ( - actualKeys.length !== expectedKeys.length - || actualKeys.some((variant, index) => variant !== expectedKeys[index]) - ) { - errors.push(`${path} must define exactly these variants: ${variantKeys.join(', ')}.`); - } - const variants: Record = {}; - for (const variant of variantKeys) { - const configured = value.variants[variant]; - if (!object(configured) || !finite(configured.value)) { - errors.push(`${path} variant "${variant}" requires a finite display value.`); - continue; - } - if (configured.expression !== undefined && !isExpression(configured.expression)) { - errors.push(`${path} variant "${variant}" has an invalid value expression.`); - continue; - } - variants[variant] = configured as ConditionalMechanicVariantValue; - } - return { - ...(value as unknown as ConditionalMechanicOutcomeDraft), - key: outcomeKey, - variants - }; + if (!object(value)) { + errors.push(`${path} must be an object.`); + return null; + } + for (const property of Object.keys(value)) { + if (!outcomeFields.has(property)) { + errors.push(`${path} contains unsupported field "${property}".`); + } + } + const outcomeKey = key(value.key, `${path} key`, errors); + if ( + !outcomeKey || + typeof value.label !== 'string' || + !value.label.trim() || + !phases.has(value.phase as OutcomePhase) || + !outcomeKinds.has(value.kind as MechanicOutcome['kind']) || + !scopes.has(value.scope as MechanicOutcomeScope) || + !bases.has(value.basis as OutcomeBasis) + ) { + errors.push( + `${path} requires a label, phase, kind, scope, and value basis.`, + ); + return null; + } + const kind = value.kind as MechanicOutcome['kind']; + if (kind === 'attribute_modifier') { + if (typeof value.attributeId !== 'string' || !value.attributeId.trim()) { + errors.push(`${path} attribute modifiers require an Attribute ID.`); + } else if (allowedAttributes && !allowedAttributes.has(value.attributeId)) { + errors.push( + `${path} references unknown Attribute "${value.attributeId}".`, + ); + } + } else if (value.attributeId !== undefined) { + errors.push( + `${path} may only use an Attribute ID for an attribute modifier.`, + ); + } + if (kind === 'restoration' || kind === 'resource_cost') { + if (!resources.has(value.resource as MechanicResource)) { + errors.push(`${path} ${kind.replace('_', ' ')} requires a resource.`); + } + } else if (value.resource !== undefined) { + errors.push( + `${path} may only use a resource for restoration or resource cost.`, + ); + } + const delivery = value.delivery ?? 'instant'; + if (!deliveryModes.has(String(delivery))) { + errors.push(`${path} has an invalid delivery mode.`); + } else if (delivery === 'continuous') { + if (kind !== 'restoration') { + errors.push(`${path} may only use continuous delivery for restoration.`); + } + if (!finite(value.durationSeconds) || value.durationSeconds <= 0) { + errors.push(`${path} continuous delivery requires a positive duration.`); + } + } else if (value.durationSeconds !== undefined) { + errors.push(`${path} may only set a duration for continuous delivery.`); + } + if ( + value.combatTiming !== undefined && + value.combatTiming !== 'pre_event' && + value.combatTiming !== 'current' + ) { + errors.push(`${path} has invalid combat timing.`); + } + if ( + value.descriptionSlot !== undefined && + (!Number.isInteger(value.descriptionSlot) || + Number(value.descriptionSlot) < 1) + ) { + errors.push(`${path} description slot must be a positive integer.`); + } + if (value.targeting !== undefined) { + if ( + !object(value.targeting) || + value.targeting.kind !== 'within_radius' || + !['allies', 'enemies'].includes(String(value.targeting.population)) || + !finite(value.targeting.radiusMeters) || + value.targeting.radiusMeters <= 0 + ) { + errors.push(`${path} has invalid radius targeting.`); + } else if ( + value.targeting.population === 'allies' && + value.scope !== 'allies' && + value.scope !== 'self_and_allies' + ) { + errors.push( + `${path} ally radius targeting requires allies or self-and-allies scope.`, + ); + } else if ( + value.targeting.population === 'enemies' && + value.scope !== 'target' + ) { + errors.push(`${path} enemy radius targeting requires target scope.`); + } + } + if (!object(value.variants)) { + errors.push(`${path} variants must be an object.`); + return null; + } + const actualKeys = Object.keys(value.variants).sort(); + const expectedKeys = [...variantKeys].sort(); + if ( + actualKeys.length !== expectedKeys.length || + actualKeys.some((variant, index) => variant !== expectedKeys[index]) + ) { + errors.push( + `${path} must define exactly these variants: ${variantKeys.join(', ')}.`, + ); + } + const variants: Record = {}; + for (const variant of variantKeys) { + const configured = value.variants[variant]; + if (!object(configured) || !finite(configured.value)) { + errors.push( + `${path} variant "${variant}" requires a finite display value.`, + ); + continue; + } + if ( + configured.expression !== undefined && + !isExpression(configured.expression) + ) { + errors.push( + `${path} variant "${variant}" has an invalid value expression.`, + ); + continue; + } + variants[variant] = configured as ConditionalMechanicVariantValue; + } + return { + ...(value as unknown as ConditionalMechanicOutcomeDraft), + key: outcomeKey, + variants, + }; } function parseClauses( - value: unknown, - variantKeys: readonly string[], - options: ParseConditionalMechanicDraftOptions, - errors: string[] + value: unknown, + variantKeys: readonly string[], + options: ParseConditionalMechanicDraftOptions, + errors: string[], ) { - if (!Array.isArray(value)) { - errors.push('Mechanic clauses must be an array.'); - return [] as ConditionalMechanicClauseDraft[]; - } - const clauses: ConditionalMechanicClauseDraft[] = []; - for (const [index, candidate] of value.entries()) { - const path = `Clause ${index + 1}`; - if (!object(candidate)) { - errors.push(`${path} must be an object.`); - continue; - } - const clauseKey = key(candidate.key, `${path} key`, errors); - if (!clauseKey || !events.has(String(candidate.event))) { - errors.push(`${path} requires a supported direct event.`); - continue; - } - if ( - candidate.recipeKey !== undefined - && ( - typeof candidate.recipeKey !== 'string' - || !candidate.recipeKey.trim() - || (options.allowedRecipeKeys && !options.allowedRecipeKeys.has(candidate.recipeKey)) - ) - ) { - errors.push(`${path} has an unknown recipe key.`); - } - if (!isPredicate(candidate.if ?? { kind: 'true' })) { - errors.push(`${path} has an invalid predicate.`); - } - if (!isReducer(candidate.reducer)) errors.push(`${path} has an invalid reducer.`); - if (candidate.weight !== undefined && !isExpression(candidate.weight)) { - errors.push(`${path} has an invalid weight expression.`); - } - if ( - candidate.cooldownSeconds !== undefined - && (!finite(candidate.cooldownSeconds) || candidate.cooldownSeconds <= 0) - ) { - errors.push(`${path} cooldown must be greater than zero.`); - } - if (!Array.isArray(candidate.mutations) || !candidate.mutations.every(isMutation)) { - errors.push(`${path} mutations must be a valid array.`); - } - if (!Array.isArray(candidate.outcomes)) { - errors.push(`${path} outcomes must be an array.`); - continue; - } - const outcomes = candidate.outcomes - .map((outcome, outcomeIndex) => parseOutcome( - outcome, - `${path} outcome ${outcomeIndex + 1}`, - variantKeys, - options.allowedAttributeIds, - errors - )) - .filter((outcome): outcome is ConditionalMechanicOutcomeDraft => outcome !== null); - clauses.push({ - ...(candidate as unknown as ConditionalMechanicClauseDraft), - key: clauseKey, - event: candidate.event as MechanicEventKind, - if: (candidate.if ?? { kind: 'true' }) as Predicate, - reducer: candidate.reducer as Reducer, - mutations: Array.isArray(candidate.mutations) ? candidate.mutations as ClauseMutation[] : [], - outcomes - }); - } - return clauses; + if (!Array.isArray(value)) { + errors.push('Mechanic clauses must be an array.'); + return [] as ConditionalMechanicClauseDraft[]; + } + const clauses: ConditionalMechanicClauseDraft[] = []; + for (const [index, candidate] of value.entries()) { + const path = `Clause ${index + 1}`; + if (!object(candidate)) { + errors.push(`${path} must be an object.`); + continue; + } + const clauseKey = key(candidate.key, `${path} key`, errors); + if (!clauseKey || !events.has(String(candidate.event))) { + errors.push(`${path} requires a supported direct event.`); + continue; + } + if ( + candidate.recipeKey !== undefined && + (typeof candidate.recipeKey !== 'string' || + !candidate.recipeKey.trim() || + (options.allowedRecipeKeys && + !options.allowedRecipeKeys.has(candidate.recipeKey))) + ) { + errors.push(`${path} has an unknown recipe key.`); + } + if (!isPredicate(candidate.if ?? { kind: 'true' })) { + errors.push(`${path} has an invalid predicate.`); + } + if (!isReducer(candidate.reducer)) + errors.push(`${path} has an invalid reducer.`); + if (candidate.weight !== undefined && !isExpression(candidate.weight)) { + errors.push(`${path} has an invalid weight expression.`); + } + if ( + candidate.cooldownSeconds !== undefined && + (!finite(candidate.cooldownSeconds) || candidate.cooldownSeconds <= 0) + ) { + errors.push(`${path} cooldown must be greater than zero.`); + } + if ( + !Array.isArray(candidate.mutations) || + !candidate.mutations.every(isMutation) + ) { + errors.push(`${path} mutations must be a valid array.`); + } + if (!Array.isArray(candidate.outcomes)) { + errors.push(`${path} outcomes must be an array.`); + continue; + } + const outcomes = candidate.outcomes + .map((outcome, outcomeIndex) => + parseOutcome( + outcome, + `${path} outcome ${outcomeIndex + 1}`, + variantKeys, + options.allowedAttributeIds, + errors, + ), + ) + .filter( + (outcome): outcome is ConditionalMechanicOutcomeDraft => + outcome !== null, + ); + clauses.push({ + ...(candidate as unknown as ConditionalMechanicClauseDraft), + key: clauseKey, + event: candidate.event as MechanicEventKind, + if: (candidate.if ?? { kind: 'true' }) as Predicate, + reducer: candidate.reducer as Reducer, + mutations: Array.isArray(candidate.mutations) + ? (candidate.mutations as ClauseMutation[]) + : [], + outcomes, + }); + } + return clauses; } function programsForDraft( - draft: ConditionalMechanicDraftV1, - options: ParseConditionalMechanicDraftOptions + draft: ConditionalMechanicDraftV1, + options: ParseConditionalMechanicDraftOptions, ) { - const programs: Record = {}; - for (const variant of options.variantKeys) { - programs[variant] = { - id: `${options.programId ?? 'mechanic'}:${variant}`, - name: options.programName?.trim() || 'Conditional Mechanic', - scenarioInputs: draft.scenarioInputs, - pools: draft.pools.map(({ - stateKind: _stateKind, - expiresAfterSecondsVariants, - ...pool - }) => ({ - ...pool, - ...(expiresAfterSecondsVariants - ? { expiresAfterSeconds: expiresAfterSecondsVariants[variant] } - : {}) - })), - clauses: draft.clauses.map((clause): MechanicClause => ({ - id: clause.key, - ...(clause.label ? { label: clause.label } : {}), - when: clause.event, - ...(clause.recipeKey ? { recipeKey: clause.recipeKey } : {}), - ...(clause.if ? { if: clause.if } : {}), - reducer: clause.reducer, - ...(clause.cooldownSeconds === undefined - ? {} - : { cooldownSeconds: clause.cooldownSeconds }), - ...(clause.weight === undefined ? {} : { weight: clause.weight }), - mutations: clause.mutations, - outcomes: clause.outcomes.map(({ key: outcomeKey, variants, descriptionSlot: _slot, ...outcome }) => ({ - ...outcome, - id: outcomeKey, - value: variants[variant].expression ?? variants[variant].value - })) - })) - }; - } - return programs; + const programs: Record = {}; + for (const variant of options.variantKeys) { + programs[variant] = { + id: `${options.programId ?? 'mechanic'}:${variant}`, + name: options.programName?.trim() || 'Conditional Mechanic', + scenarioInputs: draft.scenarioInputs, + pools: draft.pools.map( + ({ stateKind: _stateKind, expiresAfterSecondsVariants, ...pool }) => ({ + ...pool, + ...(expiresAfterSecondsVariants + ? { expiresAfterSeconds: expiresAfterSecondsVariants[variant] } + : {}), + }), + ), + clauses: draft.clauses.map((clause): MechanicClause => ({ + id: clause.key, + ...(clause.label ? { label: clause.label } : {}), + when: clause.event, + ...(clause.recipeKey ? { recipeKey: clause.recipeKey } : {}), + ...(clause.if ? { if: clause.if } : {}), + reducer: clause.reducer, + ...(clause.cooldownSeconds === undefined + ? {} + : { cooldownSeconds: clause.cooldownSeconds }), + ...(clause.weight === undefined ? {} : { weight: clause.weight }), + mutations: clause.mutations, + outcomes: clause.outcomes.map( + ({ + key: outcomeKey, + variants, + descriptionSlot: _slot, + ...outcome + }) => ({ + ...outcome, + id: outcomeKey, + value: variants[variant].expression ?? variants[variant].value, + }), + ), + })), + }; + } + return programs; } /** @@ -776,91 +927,130 @@ function programsForDraft( * Malformed payloads return errors and never escape into page rendering as throws. */ export function parseConditionalMechanicDraft( - raw: unknown, - options: ParseConditionalMechanicDraftOptions + raw: unknown, + options: ParseConditionalMechanicDraftOptions, ): ConditionalMechanicDraftResult { - const errors: string[] = []; - try { - if ( - !Array.isArray(options.variantKeys) - || !options.variantKeys.length - || options.variantKeys.some((variant) => !variant.trim()) - || new Set(options.variantKeys).size !== options.variantKeys.length - ) { - return { ok: false, errors: ['Variant keys must be unique non-empty strings.'] }; - } - const parsed = parseRaw(raw, errors); - if (!object(parsed)) return { ok: false, errors: errors.length ? errors : ['The conditional mechanic must be an object.'] }; - if (parsed.version !== CONDITIONAL_MECHANIC_DRAFT_VERSION) { - errors.push(`Conditional mechanic version ${CONDITIONAL_MECHANIC_DRAFT_VERSION} is required.`); - } - if (!['ready', 'requires_assumptions', 'captured_only'].includes(String(parsed.simulationReadiness))) { - errors.push('The conditional mechanic has invalid simulation readiness.'); - } - const scenarioInputs = parseScenarioInputs(parsed.scenarioInputs, errors); - const pools = parsePools(parsed.pools, options.variantKeys, errors); - const clauses = parseClauses(parsed.clauses, options.variantKeys, options, errors); - const draft: ConditionalMechanicDraftV1 = { - version: CONDITIONAL_MECHANIC_DRAFT_VERSION, - simulationReadiness: parsed.simulationReadiness as ConditionalMechanicDraftV1['simulationReadiness'], - scenarioInputs, - pools, - clauses - }; - validateAttributeReferences(draft, options.allowedAttributeIds, errors); - const inputRequiresAssumption = scenarioInputs.some( - (input) => input.readiness === 'requires_assumption' - ); - if (draft.simulationReadiness === 'ready' && inputRequiresAssumption) { - errors.push('Simulation readiness must require assumptions while an input requires an assumption.'); - } - if (draft.simulationReadiness !== 'captured_only' && !clauses.length) { - errors.push('A simulation-ready mechanic requires at least one clause.'); - } - const duplicate = (values: string[], label: string) => { - const seen = new Set(); - for (const value of values) { - if (seen.has(value)) errors.push(`${label} "${value}" is duplicated.`); - seen.add(value); - } - }; - duplicate(scenarioInputs.map((input) => input.key), 'Scenario input'); - duplicate(pools.map((pool) => pool.key), 'State pool'); - duplicate(clauses.map((clause) => clause.key), 'Clause'); - for (const clause of clauses) { - duplicate(clause.outcomes.map((outcome) => outcome.key), `Clause "${clause.key}" outcome`); - } - if (errors.length) return { ok: false, errors }; - const programs = programsForDraft(draft, options); - for (const [variant, program] of Object.entries(programs)) { - let validation; - try { - validation = validateMechanicProgram(program); - } catch { - errors.push(`Variant "${variant}" contains a structure the mechanic runtime cannot validate.`); - continue; - } - if (!validation.ok) { - errors.push(...validation.errors.map((error) => `Variant "${variant}": ${error}`)); - } - } - return errors.length - ? { ok: false, errors } - : { ok: true, value: { draft, programs } }; - } catch { - return { - ok: false, - errors: ['The conditional mechanic could not be parsed safely.'] - }; - } + const errors: string[] = []; + try { + if ( + !Array.isArray(options.variantKeys) || + !options.variantKeys.length || + options.variantKeys.some((variant) => !variant.trim()) || + new Set(options.variantKeys).size !== options.variantKeys.length + ) { + return { + ok: false, + errors: ['Variant keys must be unique non-empty strings.'], + }; + } + const parsed = parseRaw(raw, errors); + if (!object(parsed)) + return { + ok: false, + errors: errors.length + ? errors + : ['The conditional mechanic must be an object.'], + }; + if (parsed.version !== CONDITIONAL_MECHANIC_DRAFT_VERSION) { + errors.push( + `Conditional mechanic version ${CONDITIONAL_MECHANIC_DRAFT_VERSION} is required.`, + ); + } + if ( + !['ready', 'requires_assumptions', 'captured_only'].includes( + String(parsed.simulationReadiness), + ) + ) { + errors.push('The conditional mechanic has invalid simulation readiness.'); + } + const scenarioInputs = parseScenarioInputs(parsed.scenarioInputs, errors); + const pools = parsePools(parsed.pools, options.variantKeys, errors); + const clauses = parseClauses( + parsed.clauses, + options.variantKeys, + options, + errors, + ); + const draft: ConditionalMechanicDraftV1 = { + version: CONDITIONAL_MECHANIC_DRAFT_VERSION, + simulationReadiness: + parsed.simulationReadiness as ConditionalMechanicDraftV1['simulationReadiness'], + scenarioInputs, + pools, + clauses, + }; + validateAttributeReferences(draft, options.allowedAttributeIds, errors); + const inputRequiresAssumption = scenarioInputs.some( + (input) => input.readiness === 'requires_assumption', + ); + if (draft.simulationReadiness === 'ready' && inputRequiresAssumption) { + errors.push( + 'Simulation readiness must require assumptions while an input requires an assumption.', + ); + } + if (draft.simulationReadiness !== 'captured_only' && !clauses.length) { + errors.push('A simulation-ready mechanic requires at least one clause.'); + } + const duplicate = (values: string[], label: string) => { + const seen = new Set(); + for (const value of values) { + if (seen.has(value)) errors.push(`${label} "${value}" is duplicated.`); + seen.add(value); + } + }; + duplicate( + scenarioInputs.map((input) => input.key), + 'Scenario input', + ); + duplicate( + pools.map((pool) => pool.key), + 'State pool', + ); + duplicate( + clauses.map((clause) => clause.key), + 'Clause', + ); + for (const clause of clauses) { + duplicate( + clause.outcomes.map((outcome) => outcome.key), + `Clause "${clause.key}" outcome`, + ); + } + if (errors.length) return { ok: false, errors }; + const programs = programsForDraft(draft, options); + for (const [variant, program] of Object.entries(programs)) { + let validation; + try { + validation = validateMechanicProgram(program); + } catch { + errors.push( + `Variant "${variant}" contains a structure the mechanic runtime cannot validate.`, + ); + continue; + } + if (!validation.ok) { + errors.push( + ...validation.errors.map((error) => `Variant "${variant}": ${error}`), + ); + } + } + return errors.length + ? { ok: false, errors } + : { ok: true, value: { draft, programs } }; + } catch { + return { + ok: false, + errors: ['The conditional mechanic could not be parsed safely.'], + }; + } } export function blankConditionalMechanicDraft(): ConditionalMechanicDraftV1 { - return { - version: CONDITIONAL_MECHANIC_DRAFT_VERSION, - simulationReadiness: 'captured_only', - scenarioInputs: [], - pools: [], - clauses: [] - }; + return { + version: CONDITIONAL_MECHANIC_DRAFT_VERSION, + simulationReadiness: 'captured_only', + scenarioInputs: [], + pools: [], + clauses: [], + }; } diff --git a/src/lib/calculations/mechanic-persistence.test.ts b/src/lib/calculations/mechanic-persistence.test.ts index 68cee22..db702e9 100644 --- a/src/lib/calculations/mechanic-persistence.test.ts +++ b/src/lib/calculations/mechanic-persistence.test.ts @@ -1,675 +1,778 @@ import { describe, expect, it } from 'vitest'; import { - adaptConditionalEffectProgram, - type ConditionalEffectClauseRow, - type ConditionalEffectOutcomeRow, - type ConditionalEffectRuleRow, - type ConditionalEffectStatePoolRow + adaptConditionalEffectProgram, + type ConditionalEffectClauseRow, + type ConditionalEffectOutcomeRow, + type ConditionalEffectRuleRow, + type ConditionalEffectStatePoolRow, } from './mechanic-persistence.js'; const rule = ( - overrides: Partial = {} + overrides: Partial = {}, ): ConditionalEffectRuleRow => ({ - id: 'rule-1', - source_type: 'talent', - source_id: 'talent-1', - configured: 1, - script_version: 1, - simulation_readiness: 'ready', - scenario_inputs_json: '[]', - ...overrides + id: 'rule-1', + source_type: 'talent', + source_id: 'talent-1', + configured: 1, + script_version: 1, + simulation_readiness: 'ready', + scenario_inputs_json: '[]', + ...overrides, }); const pool = ( - overrides: Partial = {} + overrides: Partial = {}, ): ConditionalEffectStatePoolRow => ({ - id: 'pool-1', - rule_id: 'rule-1', - state_key: 'effect', - label: 'Effect', - state_kind: 'stack', - state_scope: 'agent', - initial_value: 0, - minimum_value: 0, - maximum_value: 10, - best_case_value: 10, - duration_seconds: 10, - expiry_policy: 'clear_all', - refresh_policy: 'refresh', - parameters_json: '{}', - sort_order: 1, - ...overrides + id: 'pool-1', + rule_id: 'rule-1', + state_key: 'effect', + label: 'Effect', + state_kind: 'stack', + state_scope: 'agent', + initial_value: 0, + minimum_value: 0, + maximum_value: 10, + best_case_value: 10, + duration_seconds: 10, + expiry_policy: 'clear_all', + refresh_policy: 'refresh', + parameters_json: '{}', + sort_order: 1, + ...overrides, }); const clause = ( - overrides: Partial = {} + overrides: Partial = {}, ): ConditionalEffectClauseRow => ({ - id: 'clause-1', - rule_id: 'rule-1', - clause_key: 'primary', - label: 'Primary', - trigger_id: 'trigger-1', - parameters_json: JSON.stringify({ hitsRequired: 9, windowSeconds: 3 }), - conditions_json: JSON.stringify({ kind: 'true' }), - reducer_json: JSON.stringify({ kind: 'none' }), - emission_kind: 'each_match', - state_pool_id: 'pool-1', - mutations_json: JSON.stringify([{ kind: 'add', pool: 'effect', value: 1 }]), - cooldown_seconds: null, - output_branch: 'active', - sort_order: 1, - active: 1, - ...overrides + id: 'clause-1', + rule_id: 'rule-1', + clause_key: 'primary', + label: 'Primary', + trigger_id: 'trigger-1', + parameters_json: JSON.stringify({ hitsRequired: 9, windowSeconds: 3 }), + conditions_json: JSON.stringify({ kind: 'true' }), + reducer_json: JSON.stringify({ kind: 'none' }), + emission_kind: 'each_match', + state_pool_id: 'pool-1', + mutations_json: JSON.stringify([{ kind: 'add', pool: 'effect', value: 1 }]), + cooldown_seconds: null, + output_branch: 'active', + sort_order: 1, + active: 1, + ...overrides, }); const outcome = ( - overrides: Partial = {} + overrides: Partial = {}, ): ConditionalEffectOutcomeRow => ({ - id: 'outcome-1', - rule_id: 'rule-1', - clause_id: 'clause-1', - state_pool_id: 'pool-1', - effect_key: 'weapon-damage', - variant_key: 'I', - outcome_key: 'active', - application_kind: 'active_modifier', - effect_scope: 'equipped_weapon', - stat_attribute_id: 'weapon-damage', - value: 3, - value_expression_json: null, - value_basis: 'flat', - targeting_json: null, - value_scaling: 'per_state_unit', - unit: '%', - damage_basis: null, - application_phase: 'while_active', - min_state_value: 1, - max_state_value: null, - description_slot: 1, - sort_order: 1, - ...overrides + id: 'outcome-1', + rule_id: 'rule-1', + clause_id: 'clause-1', + state_pool_id: 'pool-1', + effect_key: 'weapon-damage', + variant_key: 'I', + outcome_key: 'active', + application_kind: 'active_modifier', + effect_scope: 'equipped_weapon', + stat_attribute_id: 'weapon-damage', + value: 3, + value_expression_json: null, + value_basis: 'flat', + targeting_json: null, + value_scaling: 'per_state_unit', + unit: '%', + damage_basis: null, + application_phase: 'while_active', + min_state_value: 1, + max_state_value: null, + description_slot: 1, + sort_order: 1, + ...overrides, }); describe('conditional-effect persistence adapter', () => { - it('adapts a migrated simple same-target rule without losing its typed mechanics', () => { - const result = adaptConditionalEffectProgram({ - rule: rule(), - statePools: [pool()], - clauses: [clause()], - outcomes: [outcome()], - triggers: [{ - id: 'trigger-1', - action_key: 'same_target_hits_within_window', - name: 'Same-target hits' - }] - }, { name: 'Superior Firepower', variantKey: 'I' }); + it('adapts a migrated simple same-target rule without losing its typed mechanics', () => { + const result = adaptConditionalEffectProgram( + { + rule: rule(), + statePools: [pool()], + clauses: [clause()], + outcomes: [outcome()], + triggers: [ + { + id: 'trigger-1', + action_key: 'same_target_hits_within_window', + name: 'Same-target hits', + }, + ], + }, + { name: 'Superior Firepower', variantKey: 'I' }, + ); - expect(result).toMatchObject({ - status: 'READY', - simulationReadiness: 'ready', - errors: [] - }); - expect(result.program).toMatchObject({ - id: 'rule-1', - name: 'Superior Firepower', - pools: [{ - key: 'effect', - scope: 'agent', - initial: 0, - max: 10, - bestCaseValue: 10, - expiresAfterSeconds: 10 - }], - clauses: [{ - id: 'clause-1', - when: 'weapon_hit', - if: { - kind: 'exists', - value: { source: 'event', path: 'targetId' } - }, - reducer: { - kind: 'sliding_count', - threshold: 9, - windowSeconds: 3, - reset: 'zero', - groupBy: { source: 'event', path: 'targetId' } - }, - mutations: [{ kind: 'add', pool: 'effect', value: 1 }], - outcomes: [{ - id: 'outcome-1', - phase: 'while_active', - kind: 'attribute_modifier', - scope: 'equipped_weapon', - attributeId: 'weapon-damage', - value: 3, - basis: 'flat', - statePool: 'effect', - perStack: true, - minStacks: 1 - }] - }] - }); - expect(result.program?.clauses[0].outcomes?.[0]).not.toHaveProperty('delivery'); - expect(result.program?.clauses[0].outcomes?.[0]).not.toHaveProperty('durationSeconds'); - }); + expect(result).toMatchObject({ + status: 'READY', + simulationReadiness: 'ready', + errors: [], + }); + expect(result.program).toMatchObject({ + id: 'rule-1', + name: 'Superior Firepower', + pools: [ + { + key: 'effect', + scope: 'agent', + initial: 0, + max: 10, + bestCaseValue: 10, + expiresAfterSeconds: 10, + }, + ], + clauses: [ + { + id: 'clause-1', + when: 'weapon_hit', + if: { + kind: 'exists', + value: { source: 'event', path: 'targetId' }, + }, + reducer: { + kind: 'sliding_count', + threshold: 9, + windowSeconds: 3, + reset: 'zero', + groupBy: { source: 'event', path: 'targetId' }, + }, + mutations: [{ kind: 'add', pool: 'effect', value: 1 }], + outcomes: [ + { + id: 'outcome-1', + phase: 'while_active', + kind: 'attribute_modifier', + scope: 'equipped_weapon', + attributeId: 'weapon-damage', + value: 3, + basis: 'flat', + statePool: 'effect', + perStack: true, + minStacks: 1, + }, + ], + }, + ], + }); + expect(result.program?.clauses[0].outcomes?.[0]).not.toHaveProperty( + 'delivery', + ); + expect(result.program?.clauses[0].outcomes?.[0]).not.toHaveProperty( + 'durationSeconds', + ); + }); - it('preserves radius targeting metadata on an outcome', () => { - const result = adaptConditionalEffectProgram({ - rule: rule(), - statePools: [pool()], - clauses: [clause({ trigger_id: null, event_kind: 'enemy_killed' })], - outcomes: [outcome({ - effect_scope: 'allies', - targeting_json: JSON.stringify({ - kind: 'within_radius', - population: 'allies', - radiusMeters: 40 - }) - })], - triggers: [] - }, { name: 'Slaughter Healing', variantKey: 'I' }); + it('preserves radius targeting metadata on an outcome', () => { + const result = adaptConditionalEffectProgram( + { + rule: rule(), + statePools: [pool()], + clauses: [clause({ trigger_id: null, event_kind: 'enemy_killed' })], + outcomes: [ + outcome({ + effect_scope: 'allies', + targeting_json: JSON.stringify({ + kind: 'within_radius', + population: 'allies', + radiusMeters: 40, + }), + }), + ], + triggers: [], + }, + { name: 'Slaughter Healing', variantKey: 'I' }, + ); - expect(result.errors).toEqual([]); - expect(result.program?.clauses[0].outcomes?.[0]).toMatchObject({ - scope: 'allies', - targeting: { - kind: 'within_radius', - population: 'allies', - radiusMeters: 40 - } - }); - }); + expect(result.errors).toEqual([]); + expect(result.program?.clauses[0].outcomes?.[0]).toMatchObject({ + scope: 'allies', + targeting: { + kind: 'within_radius', + population: 'allies', + radiusMeters: 40, + }, + }); + }); - it('loads continuous restoration delivery metadata', () => { - const result = adaptConditionalEffectProgram({ - rule: rule(), - statePools: [], - clauses: [clause({ - trigger_id: null, - event_kind: 'enemy_killed', - parameters_json: '{}', - state_pool_id: null, - mutations_json: '[]' - })], - outcomes: [outcome({ - state_pool_id: null, - effect_key: 'predatory-restoration', - application_kind: 'restore_health', - effect_scope: 'self', - stat_attribute_id: null, - value: 240, - value_scaling: 'flat', - application_phase: 'after_event', - resource_kind: 'health', - delivery_mode: 'continuous', - delivery_duration_seconds: 3, - min_state_value: null - })], - triggers: [] - }, { name: 'Predatory Aura', variantKey: 'I' }); + it('loads continuous restoration delivery metadata', () => { + const result = adaptConditionalEffectProgram( + { + rule: rule(), + statePools: [], + clauses: [ + clause({ + trigger_id: null, + event_kind: 'enemy_killed', + parameters_json: '{}', + state_pool_id: null, + mutations_json: '[]', + }), + ], + outcomes: [ + outcome({ + state_pool_id: null, + effect_key: 'predatory-restoration', + application_kind: 'restore_health', + effect_scope: 'self', + stat_attribute_id: null, + value: 240, + value_scaling: 'flat', + application_phase: 'after_event', + resource_kind: 'health', + delivery_mode: 'continuous', + delivery_duration_seconds: 3, + min_state_value: null, + }), + ], + triggers: [], + }, + { name: 'Predatory Aura', variantKey: 'I' }, + ); - expect(result.errors).toEqual([]); - expect(result.program?.clauses[0].outcomes?.[0]).toMatchObject({ - kind: 'restoration', - resource: 'health', - delivery: 'continuous', - durationSeconds: 3 - }); - }); + expect(result.errors).toEqual([]); + expect(result.program?.clauses[0].outcomes?.[0]).toMatchObject({ + kind: 'restoration', + resource: 'health', + delivery: 'continuous', + durationSeconds: 3, + }); + }); - it('rejects incomplete continuous restoration metadata', () => { - const result = adaptConditionalEffectProgram({ - rule: rule(), - statePools: [], - clauses: [clause({ - trigger_id: null, - event_kind: 'enemy_killed', - parameters_json: '{}', - state_pool_id: null, - mutations_json: '[]' - })], - outcomes: [outcome({ - state_pool_id: null, - application_kind: 'restore_health', - stat_attribute_id: null, - resource_kind: 'health', - delivery_mode: 'continuous', - delivery_duration_seconds: null - })], - triggers: [] - }, { variantKey: 'I' }); + it('rejects incomplete continuous restoration metadata', () => { + const result = adaptConditionalEffectProgram( + { + rule: rule(), + statePools: [], + clauses: [ + clause({ + trigger_id: null, + event_kind: 'enemy_killed', + parameters_json: '{}', + state_pool_id: null, + mutations_json: '[]', + }), + ], + outcomes: [ + outcome({ + state_pool_id: null, + application_kind: 'restore_health', + stat_attribute_id: null, + resource_kind: 'health', + delivery_mode: 'continuous', + delivery_duration_seconds: null, + }), + ], + triggers: [], + }, + { variantKey: 'I' }, + ); - expect(result.status).toBe('INVALID'); - expect(result.errors).toContain('Outcome 1 continuous delivery requires a positive duration.'); - }); + expect(result.status).toBe('INVALID'); + expect(result.errors).toContain( + 'Outcome 1 continuous delivery requires a positive duration.', + ); + }); - it('preserves target pool mutations, exit outcomes, and live Attribute references', () => { - const firepowerMultiplierId = '82a95611-283b-4fd8-a6ed-3567a316f36e'; - const result = adaptConditionalEffectProgram({ - rule: rule(), - statePools: [pool({ - state_key: 'marked-target', - label: 'Marked Target', - state_kind: 'target_mark', - state_scope: 'target', - maximum_value: 1, - best_case_value: 1 - })], - clauses: [clause({ - trigger_id: null, - event_kind: 'enemy_killed', - parameters_json: '{}', - mutations_json: JSON.stringify([{ - kind: 'set', - pool: 'marked-target', - entity: { kind: 'event_target' }, - value: 1 - }]) - })], - outcomes: [outcome({ - effect_key: 'marked-target-detonation', - application_kind: 'direct_damage', - effect_scope: 'target', - stat_attribute_id: null, - value: 240, - value_expression_json: JSON.stringify({ - op: 'multiply', - left: 240, - right: { - op: 'divide', - left: { - ref: { - source: 'attribute', - attributeId: firepowerMultiplierId - } - }, - right: 100 - } - }), - value_scaling: 'per_state_unit', - damage_basis: 'flat', - application_phase: 'on_exit', - min_state_value: null - })], - triggers: [] - }, { name: 'Target Detonation', variantKey: 'I' }); + it('preserves target pool mutations, exit outcomes, and live Attribute references', () => { + const firepowerMultiplierId = '82a95611-283b-4fd8-a6ed-3567a316f36e'; + const result = adaptConditionalEffectProgram( + { + rule: rule(), + statePools: [ + pool({ + state_key: 'marked-target', + label: 'Marked Target', + state_kind: 'target_mark', + state_scope: 'target', + maximum_value: 1, + best_case_value: 1, + }), + ], + clauses: [ + clause({ + trigger_id: null, + event_kind: 'enemy_killed', + parameters_json: '{}', + mutations_json: JSON.stringify([ + { + kind: 'set', + pool: 'marked-target', + entity: { kind: 'event_target' }, + value: 1, + }, + ]), + }), + ], + outcomes: [ + outcome({ + effect_key: 'marked-target-detonation', + application_kind: 'direct_damage', + effect_scope: 'target', + stat_attribute_id: null, + value: 240, + value_expression_json: JSON.stringify({ + op: 'multiply', + left: 240, + right: { + op: 'divide', + left: { + ref: { + source: 'attribute', + attributeId: firepowerMultiplierId, + }, + }, + right: 100, + }, + }), + value_scaling: 'per_state_unit', + damage_basis: 'flat', + application_phase: 'on_exit', + min_state_value: null, + }), + ], + triggers: [], + }, + { name: 'Target Detonation', variantKey: 'I' }, + ); - expect(result.errors).toEqual([]); - expect(result.program?.pools?.[0]).toMatchObject({ - key: 'marked-target', - scope: 'target' - }); - expect(result.program?.clauses[0]).toMatchObject({ - mutations: [{ - kind: 'set', - pool: 'marked-target', - entity: { kind: 'event_target' }, - value: 1 - }], - outcomes: [{ - phase: 'on_exit', - kind: 'direct_damage', - scope: 'target', - statePool: 'marked-target', - perStack: true, - value: { - op: 'multiply', - left: 240, - right: { - op: 'divide', - left: { - ref: { - source: 'attribute', - attributeId: firepowerMultiplierId - } - }, - right: 100 - } - } - }] - }); - }); + expect(result.errors).toEqual([]); + expect(result.program?.pools?.[0]).toMatchObject({ + key: 'marked-target', + scope: 'target', + }); + expect(result.program?.clauses[0]).toMatchObject({ + mutations: [ + { + kind: 'set', + pool: 'marked-target', + entity: { kind: 'event_target' }, + value: 1, + }, + ], + outcomes: [ + { + phase: 'on_exit', + kind: 'direct_damage', + scope: 'target', + statePool: 'marked-target', + perStack: true, + value: { + op: 'multiply', + left: 240, + right: { + op: 'divide', + left: { + ref: { + source: 'attribute', + attributeId: firepowerMultiplierId, + }, + }, + right: 100, + }, + }, + }, + ], + }); + }); - it('rejects malformed mutation entity selectors from persisted JSON', () => { - const result = adaptConditionalEffectProgram({ - rule: rule(), - statePools: [pool({ state_scope: 'target' })], - clauses: [clause({ - mutations_json: JSON.stringify([{ - kind: 'set', - pool: 'effect', - entity: { kind: 'event_field', path: '' }, - value: 1 - }]) - })], - outcomes: [outcome()], - triggers: [{ id: 'trigger-1', action_key: 'same_target_hits_within_window' }] - }, { variantKey: 'I' }); + it('rejects malformed mutation entity selectors from persisted JSON', () => { + const result = adaptConditionalEffectProgram( + { + rule: rule(), + statePools: [pool({ state_scope: 'target' })], + clauses: [ + clause({ + mutations_json: JSON.stringify([ + { + kind: 'set', + pool: 'effect', + entity: { kind: 'event_field', path: '' }, + value: 1, + }, + ]), + }), + ], + outcomes: [outcome()], + triggers: [ + { id: 'trigger-1', action_key: 'same_target_hits_within_window' }, + ], + }, + { variantKey: 'I' }, + ); - expect(result.status).toBe('INVALID'); - expect(result.errors).toContain('Clause 1 mutations must be an array of valid state mutations.'); - }); + expect(result.status).toBe('INVALID'); + expect(result.errors).toContain( + 'Clause 1 mutations must be an array of valid state mutations.', + ); + }); - it('adapts the Happy Survival branches, explicit cadence, health timing, and upgrade references', () => { - const upgradeScale = { - op: 'add', - left: 1, - right: { - op: 'divide', - left: { ref: { source: 'equipment', path: 'protocolTalentUpgrades' } }, - right: 5 - } - }; - const result = adaptConditionalEffectProgram({ - rule: rule({ - source_type: 'os_protocol_talent', - source_id: 'happy-survival', - simulation_readiness: 'requires_assumptions', - scenario_inputs_json: JSON.stringify([{ - key: 'restorationIntervalSeconds', - label: 'Restoration interval', - kind: 'number', - readiness: 'requires_assumption', - description: 'Enter the verified talent cadence.', - min: 0.01 - }]) - }), - statePools: [pool({ - state_key: 'nearby-enemies', - label: 'Nearby Enemies', - state_kind: 'count', - maximum_value: 20, - best_case_value: 5, - duration_seconds: null - })], - clauses: [ - clause({ - id: 'sample-nearby', - clause_key: 'sample-nearby', - label: 'Sample nearby enemies', - trigger_id: null, - event_kind: 'proximity_snapshot', - parameters_json: '{}', - reducer_json: JSON.stringify({ - kind: 'derived_scalar', - value: { ref: { source: 'event', path: 'values.enemiesWithinRadius' } } - }), - mutations_json: JSON.stringify([{ - kind: 'derive', - pool: 'nearby-enemies', - value: { ref: { source: 'reducer' } } - }]), - sort_order: 1 - }), - clause({ - id: 'restore-nearby', - clause_key: 'restore-nearby', - label: 'Restore by nearby enemies', - trigger_id: null, - event_kind: 'tick', - weight_expression_json: JSON.stringify({ - op: 'divide', - left: { ref: { source: 'scenario', key: 'restorationIntervalSeconds' } }, - right: 60 - }), - parameters_json: '{}', - reducer_json: JSON.stringify({ - kind: 'periodic', - intervalSeconds: { - ref: { - source: 'scenario', - key: 'restorationIntervalSeconds' - } - } - }), - mutations_json: JSON.stringify([{ kind: 'none' }]), - state_pool_id: null, - sort_order: 2 - }), - clause({ - id: 'shot-health-exchange', - clause_key: 'shot-health-exchange', - label: 'Shot health exchange', - trigger_id: null, - event_kind: 'shot_fired', - recipe_key: 'happy-survival-shot', - parameters_json: '{}', - mutations_json: JSON.stringify([{ kind: 'none' }]), - state_pool_id: null, - sort_order: 3 - }) - ], - outcomes: [ - outcome({ - id: 'restore-health', - clause_id: 'restore-nearby', - state_pool_id: 'pool-1', - effect_key: 'nearby-restoration', - variant_key: 'base', - application_kind: 'restore_health', - effect_scope: 'self', - stat_attribute_id: null, - value: 0.5, - value_expression_json: JSON.stringify({ - op: 'multiply', - left: { - op: 'clamp', - value: { - op: 'multiply', - left: { ref: { source: 'pool', pool: 'nearby-enemies' } }, - right: 0.5 - }, - min: 0, - max: 2.5 - }, - right: upgradeScale - }), - value_basis: 'max_health_percent', - resource_kind: 'health', - value_scaling: 'flat', - application_phase: 'after_event', - min_state_value: null, - sort_order: 1 - }), - outcome({ - id: 'health-cost', - clause_id: 'shot-health-exchange', - state_pool_id: null, - effect_key: 'shot-health-cost', - variant_key: 'base', - application_kind: 'resource_cost', - effect_scope: 'self', - stat_attribute_id: null, - value: 2, - value_basis: 'max_health_percent', - resource_kind: 'health', - value_scaling: 'flat', - application_phase: 'on_event', - min_state_value: null, - sort_order: 2 - }), - outcome({ - id: 'bonus-damage', - clause_id: 'shot-health-exchange', - state_pool_id: null, - effect_key: 'current-health-bonus-damage', - variant_key: 'base', - application_kind: 'direct_damage', - effect_scope: 'target', - stat_attribute_id: null, - value: 0.6, - value_expression_json: JSON.stringify({ - op: 'multiply', - left: 0.6, - right: upgradeScale - }), - value_basis: 'current_health_percent', - combat_timing: 'current', - value_scaling: 'flat', - damage_basis: 'flat', - application_phase: 'on_event', - min_state_value: null, - sort_order: 3 - }) - ], - triggers: [] - }, { name: 'Happy Survival', variantKey: 'base' }); + it('adapts the Happy Survival branches, explicit cadence, health timing, and upgrade references', () => { + const upgradeScale = { + op: 'add', + left: 1, + right: { + op: 'divide', + left: { ref: { source: 'equipment', path: 'protocolTalentUpgrades' } }, + right: 5, + }, + }; + const result = adaptConditionalEffectProgram( + { + rule: rule({ + source_type: 'os_protocol_talent', + source_id: 'happy-survival', + simulation_readiness: 'requires_assumptions', + scenario_inputs_json: JSON.stringify([ + { + key: 'restorationIntervalSeconds', + label: 'Restoration interval', + kind: 'number', + readiness: 'requires_assumption', + description: 'Enter the verified talent cadence.', + min: 0.01, + }, + ]), + }), + statePools: [ + pool({ + state_key: 'nearby-enemies', + label: 'Nearby Enemies', + state_kind: 'count', + maximum_value: 20, + best_case_value: 5, + duration_seconds: null, + }), + ], + clauses: [ + clause({ + id: 'sample-nearby', + clause_key: 'sample-nearby', + label: 'Sample nearby enemies', + trigger_id: null, + event_kind: 'proximity_snapshot', + parameters_json: '{}', + reducer_json: JSON.stringify({ + kind: 'derived_scalar', + value: { + ref: { source: 'event', path: 'values.enemiesWithinRadius' }, + }, + }), + mutations_json: JSON.stringify([ + { + kind: 'derive', + pool: 'nearby-enemies', + value: { ref: { source: 'reducer' } }, + }, + ]), + sort_order: 1, + }), + clause({ + id: 'restore-nearby', + clause_key: 'restore-nearby', + label: 'Restore by nearby enemies', + trigger_id: null, + event_kind: 'tick', + weight_expression_json: JSON.stringify({ + op: 'divide', + left: { + ref: { source: 'scenario', key: 'restorationIntervalSeconds' }, + }, + right: 60, + }), + parameters_json: '{}', + reducer_json: JSON.stringify({ + kind: 'periodic', + intervalSeconds: { + ref: { + source: 'scenario', + key: 'restorationIntervalSeconds', + }, + }, + }), + mutations_json: JSON.stringify([{ kind: 'none' }]), + state_pool_id: null, + sort_order: 2, + }), + clause({ + id: 'shot-health-exchange', + clause_key: 'shot-health-exchange', + label: 'Shot health exchange', + trigger_id: null, + event_kind: 'shot_fired', + recipe_key: 'happy-survival-shot', + parameters_json: '{}', + mutations_json: JSON.stringify([{ kind: 'none' }]), + state_pool_id: null, + sort_order: 3, + }), + ], + outcomes: [ + outcome({ + id: 'restore-health', + clause_id: 'restore-nearby', + state_pool_id: 'pool-1', + effect_key: 'nearby-restoration', + variant_key: 'base', + application_kind: 'restore_health', + effect_scope: 'self', + stat_attribute_id: null, + value: 0.5, + value_expression_json: JSON.stringify({ + op: 'multiply', + left: { + op: 'clamp', + value: { + op: 'multiply', + left: { ref: { source: 'pool', pool: 'nearby-enemies' } }, + right: 0.5, + }, + min: 0, + max: 2.5, + }, + right: upgradeScale, + }), + value_basis: 'max_health_percent', + resource_kind: 'health', + value_scaling: 'flat', + application_phase: 'after_event', + min_state_value: null, + sort_order: 1, + }), + outcome({ + id: 'health-cost', + clause_id: 'shot-health-exchange', + state_pool_id: null, + effect_key: 'shot-health-cost', + variant_key: 'base', + application_kind: 'resource_cost', + effect_scope: 'self', + stat_attribute_id: null, + value: 2, + value_basis: 'max_health_percent', + resource_kind: 'health', + value_scaling: 'flat', + application_phase: 'on_event', + min_state_value: null, + sort_order: 2, + }), + outcome({ + id: 'bonus-damage', + clause_id: 'shot-health-exchange', + state_pool_id: null, + effect_key: 'current-health-bonus-damage', + variant_key: 'base', + application_kind: 'direct_damage', + effect_scope: 'target', + stat_attribute_id: null, + value: 0.6, + value_expression_json: JSON.stringify({ + op: 'multiply', + left: 0.6, + right: upgradeScale, + }), + value_basis: 'current_health_percent', + combat_timing: 'current', + value_scaling: 'flat', + damage_basis: 'flat', + application_phase: 'on_event', + min_state_value: null, + sort_order: 3, + }), + ], + triggers: [], + }, + { name: 'Happy Survival', variantKey: 'base' }, + ); - expect(result.errors).toEqual([]); - expect(result.status).toBe('REQUIRES_ASSUMPTIONS'); - expect(result.program?.scenarioInputs?.[0]).toMatchObject({ - key: 'restorationIntervalSeconds', - readiness: 'requires_assumption' - }); - expect(result.program?.clauses.map(({ when }) => when)).toEqual([ - 'proximity_snapshot', - 'tick', - 'shot_fired' - ]); - expect(result.program?.clauses[1].weight).toEqual({ - op: 'divide', - left: { ref: { source: 'scenario', key: 'restorationIntervalSeconds' } }, - right: 60 - }); - expect(result.program?.clauses[2].recipeKey).toBe('happy-survival-shot'); - expect(result.program?.clauses[1].reducer).toEqual({ - kind: 'periodic', - intervalSeconds: { - ref: { - source: 'scenario', - key: 'restorationIntervalSeconds' - } - } - }); - const restoration = result.program?.clauses[1].outcomes?.[0]; - const shotOutcomes = result.program?.clauses[2].outcomes; - expect(restoration).toMatchObject({ - kind: 'restoration', - phase: 'after_event', - basis: 'max_health_percent', - value: expect.objectContaining({ - op: 'multiply', - right: expect.objectContaining({ - op: 'add', - right: expect.objectContaining({ - left: { - ref: { - source: 'equipment', - path: 'protocolTalentUpgrades' - } - } - }) - }) - }) - }); - expect(shotOutcomes).toEqual([ - expect.objectContaining({ - id: 'health-cost', - kind: 'resource_cost', - resource: 'health', - phase: 'on_event', - basis: 'max_health_percent', - value: 2 - }), - expect.objectContaining({ - id: 'bonus-damage', - kind: 'direct_damage', - phase: 'on_event', - basis: 'current_health_percent', - combatTiming: 'current', - value: expect.objectContaining({ - left: 0.6, - right: expect.objectContaining({ - right: expect.objectContaining({ - left: { - ref: { - source: 'equipment', - path: 'protocolTalentUpgrades' - } - } - }) - }) - }) - }) - ]); - }); + expect(result.errors).toEqual([]); + expect(result.status).toBe('REQUIRES_ASSUMPTIONS'); + expect(result.program?.scenarioInputs?.[0]).toMatchObject({ + key: 'restorationIntervalSeconds', + readiness: 'requires_assumption', + }); + expect(result.program?.clauses.map(({ when }) => when)).toEqual([ + 'proximity_snapshot', + 'tick', + 'shot_fired', + ]); + expect(result.program?.clauses[1].weight).toEqual({ + op: 'divide', + left: { ref: { source: 'scenario', key: 'restorationIntervalSeconds' } }, + right: 60, + }); + expect(result.program?.clauses[2].recipeKey).toBe('happy-survival-shot'); + expect(result.program?.clauses[1].reducer).toEqual({ + kind: 'periodic', + intervalSeconds: { + ref: { + source: 'scenario', + key: 'restorationIntervalSeconds', + }, + }, + }); + const restoration = result.program?.clauses[1].outcomes?.[0]; + const shotOutcomes = result.program?.clauses[2].outcomes; + expect(restoration).toMatchObject({ + kind: 'restoration', + phase: 'after_event', + basis: 'max_health_percent', + value: expect.objectContaining({ + op: 'multiply', + right: expect.objectContaining({ + op: 'add', + right: expect.objectContaining({ + left: { + ref: { + source: 'equipment', + path: 'protocolTalentUpgrades', + }, + }, + }), + }), + }), + }); + expect(shotOutcomes).toEqual([ + expect.objectContaining({ + id: 'health-cost', + kind: 'resource_cost', + resource: 'health', + phase: 'on_event', + basis: 'max_health_percent', + value: 2, + }), + expect.objectContaining({ + id: 'bonus-damage', + kind: 'direct_damage', + phase: 'on_event', + basis: 'current_health_percent', + combatTiming: 'current', + value: expect.objectContaining({ + left: 0.6, + right: expect.objectContaining({ + right: expect.objectContaining({ + left: { + ref: { + source: 'equipment', + path: 'protocolTalentUpgrades', + }, + }, + }), + }), + }), + }), + ]); + }); - it('returns INVALID instead of throwing when persisted JSON is malformed', () => { - expect(() => adaptConditionalEffectProgram({ - rule: rule({ scenario_inputs_json: '[not-json' }), - statePools: [pool()], - clauses: [clause({ mutations_json: '{broken' })], - outcomes: [outcome()], - triggers: [{ id: 'trigger-1', action_key: 'same_target_hits_within_window' }] - }, { variantKey: 'I' })).not.toThrow(); + it('returns INVALID instead of throwing when persisted JSON is malformed', () => { + expect(() => + adaptConditionalEffectProgram( + { + rule: rule({ scenario_inputs_json: '[not-json' }), + statePools: [pool()], + clauses: [clause({ mutations_json: '{broken' })], + outcomes: [outcome()], + triggers: [ + { id: 'trigger-1', action_key: 'same_target_hits_within_window' }, + ], + }, + { variantKey: 'I' }, + ), + ).not.toThrow(); - const result = adaptConditionalEffectProgram({ - rule: rule({ scenario_inputs_json: '[not-json' }), - statePools: [pool()], - clauses: [clause({ mutations_json: '{broken' })], - outcomes: [outcome()], - triggers: [{ id: 'trigger-1', action_key: 'same_target_hits_within_window' }] - }, { variantKey: 'I' }); - expect(result.status).toBe('INVALID'); - expect(result.program).toBeNull(); - expect(result.errors).toEqual(expect.arrayContaining([ - 'Rule scenario inputs contains malformed JSON.', - 'Clause 1 mutations contains malformed JSON.' - ])); - }); + const result = adaptConditionalEffectProgram( + { + rule: rule({ scenario_inputs_json: '[not-json' }), + statePools: [pool()], + clauses: [clause({ mutations_json: '{broken' })], + outcomes: [outcome()], + triggers: [ + { id: 'trigger-1', action_key: 'same_target_hits_within_window' }, + ], + }, + { variantKey: 'I' }, + ); + expect(result.status).toBe('INVALID'); + expect(result.program).toBeNull(); + expect(result.errors).toEqual( + expect.arrayContaining([ + 'Rule scenario inputs contains malformed JSON.', + 'Clause 1 mutations contains malformed JSON.', + ]), + ); + }); - it('preserves a persisted current Health percentage predicate', () => { - const condition = { - kind: 'compare', - left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' }, - operator: 'lt', - right: 25 - }; - const result = adaptConditionalEffectProgram({ - rule: rule(), - statePools: [], - clauses: [clause({ - trigger_id: null, - event_kind: 'tick', - parameters_json: '{}', - conditions_json: JSON.stringify(condition), - state_pool_id: null, - mutations_json: '[]' - })], - outcomes: [], - triggers: [] - }, { name: 'Spirited', variantKey: 'I' }); + it('preserves a persisted current Health percentage predicate', () => { + const condition = { + kind: 'compare', + left: { + source: 'combat', + path: 'currentHealthPercent', + timing: 'current', + }, + operator: 'lt', + right: 25, + }; + const result = adaptConditionalEffectProgram( + { + rule: rule(), + statePools: [], + clauses: [ + clause({ + trigger_id: null, + event_kind: 'tick', + parameters_json: '{}', + conditions_json: JSON.stringify(condition), + state_pool_id: null, + mutations_json: '[]', + }), + ], + outcomes: [], + triggers: [], + }, + { name: 'Spirited', variantKey: 'I' }, + ); - expect(result.status).toBe('READY'); - expect(result.program?.clauses[0].if).toEqual(condition); - }); + expect(result.status).toBe('READY'); + expect(result.program?.clauses[0].if).toEqual(condition); + }); - it('round-trips the direct Extra Health event and its required-state predicate', () => { - const condition = { - kind: 'compare', - left: { source: 'event', path: 'values.hasExtraHealth' }, - operator: 'eq', - right: true - }; - const result = adaptConditionalEffectProgram({ - rule: rule(), - statePools: [], - clauses: [clause({ - trigger_id: null, - event_kind: 'extra_health_changed', - parameters_json: '{}', - conditions_json: JSON.stringify(condition), - state_pool_id: null, - mutations_json: '[]' - })], - outcomes: [], - triggers: [] - }, { name: 'Extra Health State', variantKey: 'I' }); + it('round-trips the direct Extra Health event and its required-state predicate', () => { + const condition = { + kind: 'compare', + left: { source: 'event', path: 'values.hasExtraHealth' }, + operator: 'eq', + right: true, + }; + const result = adaptConditionalEffectProgram( + { + rule: rule(), + statePools: [], + clauses: [ + clause({ + trigger_id: null, + event_kind: 'extra_health_changed', + parameters_json: '{}', + conditions_json: JSON.stringify(condition), + state_pool_id: null, + mutations_json: '[]', + }), + ], + outcomes: [], + triggers: [], + }, + { name: 'Extra Health State', variantKey: 'I' }, + ); - expect(result).toMatchObject({ status: 'READY', errors: [] }); - expect(result.program?.clauses[0]).toMatchObject({ - when: 'extra_health_changed', - if: condition - }); - }); + expect(result).toMatchObject({ status: 'READY', errors: [] }); + expect(result.program?.clauses[0]).toMatchObject({ + when: 'extra_health_changed', + if: condition, + }); + }); }); diff --git a/src/lib/calculations/mechanic-persistence.ts b/src/lib/calculations/mechanic-persistence.ts index f1e25bf..5926075 100644 --- a/src/lib/calculations/mechanic-persistence.ts +++ b/src/lib/calculations/mechanic-persistence.ts @@ -1,634 +1,784 @@ import { - MECHANIC_EVENT_KINDS, - validateMechanicProgram, - type ClauseMutation, - type MechanicClause, - type MechanicEventKind, - type MechanicOutcome, - type MechanicProgram, - type NumericExpression, - type OutcomeBasis, - type OutcomePhase, - type PoolDefinition, - type Predicate, - type Reducer, - type ScenarioInputDefinition + MECHANIC_EVENT_KINDS, + validateMechanicProgram, + type ClauseMutation, + type MechanicClause, + type MechanicEventKind, + type MechanicOutcome, + type MechanicProgram, + type NumericExpression, + type OutcomeBasis, + type OutcomePhase, + type PoolDefinition, + type Predicate, + type Reducer, + type ScenarioInputDefinition, } from './mechanic.js'; type DatabaseValue = string | number | bigint | boolean | null | undefined; export type ConditionalEffectRuleRow = { - id: DatabaseValue; - source_type: DatabaseValue; - source_id: DatabaseValue; - configured: DatabaseValue; - script_version: DatabaseValue; - simulation_readiness: DatabaseValue; - scenario_inputs_json: DatabaseValue; + id: DatabaseValue; + source_type: DatabaseValue; + source_id: DatabaseValue; + configured: DatabaseValue; + script_version: DatabaseValue; + simulation_readiness: DatabaseValue; + scenario_inputs_json: DatabaseValue; }; export type ConditionalEffectStatePoolRow = { - id: DatabaseValue; - rule_id: DatabaseValue; - state_key: DatabaseValue; - label: DatabaseValue; - state_kind: DatabaseValue; - state_scope: DatabaseValue; - initial_value: DatabaseValue; - minimum_value: DatabaseValue; - maximum_value: DatabaseValue; - best_case_value: DatabaseValue; - duration_seconds: DatabaseValue; - expiry_policy: DatabaseValue; - refresh_policy: DatabaseValue; - parameters_json: DatabaseValue; - sort_order: DatabaseValue; + id: DatabaseValue; + rule_id: DatabaseValue; + state_key: DatabaseValue; + label: DatabaseValue; + state_kind: DatabaseValue; + state_scope: DatabaseValue; + initial_value: DatabaseValue; + minimum_value: DatabaseValue; + maximum_value: DatabaseValue; + best_case_value: DatabaseValue; + duration_seconds: DatabaseValue; + expiry_policy: DatabaseValue; + refresh_policy: DatabaseValue; + parameters_json: DatabaseValue; + sort_order: DatabaseValue; }; export type ConditionalEffectClauseRow = { - id: DatabaseValue; - rule_id: DatabaseValue; - clause_key: DatabaseValue; - label: DatabaseValue; - trigger_id: DatabaseValue; - event_kind?: DatabaseValue; - recipe_key?: DatabaseValue; - parameters_json: DatabaseValue; - conditions_json: DatabaseValue; - reducer_json: DatabaseValue; - weight_expression_json?: DatabaseValue; - emission_kind: DatabaseValue; - state_pool_id: DatabaseValue; - mutations_json: DatabaseValue; - cooldown_seconds: DatabaseValue; - output_branch: DatabaseValue; - sort_order: DatabaseValue; - active: DatabaseValue; + id: DatabaseValue; + rule_id: DatabaseValue; + clause_key: DatabaseValue; + label: DatabaseValue; + trigger_id: DatabaseValue; + event_kind?: DatabaseValue; + recipe_key?: DatabaseValue; + parameters_json: DatabaseValue; + conditions_json: DatabaseValue; + reducer_json: DatabaseValue; + weight_expression_json?: DatabaseValue; + emission_kind: DatabaseValue; + state_pool_id: DatabaseValue; + mutations_json: DatabaseValue; + cooldown_seconds: DatabaseValue; + output_branch: DatabaseValue; + sort_order: DatabaseValue; + active: DatabaseValue; }; export type ConditionalEffectOutcomeRow = { - id: DatabaseValue; - rule_id: DatabaseValue; - clause_id: DatabaseValue; - state_pool_id: DatabaseValue; - effect_key: DatabaseValue; - variant_key: DatabaseValue; - outcome_key: DatabaseValue; - application_kind: DatabaseValue; - effect_scope: DatabaseValue; - stat_attribute_id: DatabaseValue; - value: DatabaseValue; - value_expression_json: DatabaseValue; - value_basis: DatabaseValue; - resource_kind?: DatabaseValue; - combat_timing?: DatabaseValue; - delivery_mode?: DatabaseValue; - delivery_duration_seconds?: DatabaseValue; - targeting_json?: DatabaseValue; - value_scaling: DatabaseValue; - unit: DatabaseValue; - damage_basis: DatabaseValue; - application_phase: DatabaseValue; - min_state_value: DatabaseValue; - max_state_value: DatabaseValue; - description_slot: DatabaseValue; - sort_order: DatabaseValue; + id: DatabaseValue; + rule_id: DatabaseValue; + clause_id: DatabaseValue; + state_pool_id: DatabaseValue; + effect_key: DatabaseValue; + variant_key: DatabaseValue; + outcome_key: DatabaseValue; + application_kind: DatabaseValue; + effect_scope: DatabaseValue; + stat_attribute_id: DatabaseValue; + value: DatabaseValue; + value_expression_json: DatabaseValue; + value_basis: DatabaseValue; + resource_kind?: DatabaseValue; + combat_timing?: DatabaseValue; + delivery_mode?: DatabaseValue; + delivery_duration_seconds?: DatabaseValue; + targeting_json?: DatabaseValue; + value_scaling: DatabaseValue; + unit: DatabaseValue; + damage_basis: DatabaseValue; + application_phase: DatabaseValue; + min_state_value: DatabaseValue; + max_state_value: DatabaseValue; + description_slot: DatabaseValue; + sort_order: DatabaseValue; }; export type TriggerActionMetadataRow = { - id: DatabaseValue; - action_key: DatabaseValue; - event_kind?: DatabaseValue; - name?: DatabaseValue; + id: DatabaseValue; + action_key: DatabaseValue; + event_kind?: DatabaseValue; + name?: DatabaseValue; }; export type MechanicPersistenceInput = { - rule: ConditionalEffectRuleRow; - statePools: ConditionalEffectStatePoolRow[]; - clauses: ConditionalEffectClauseRow[]; - outcomes: ConditionalEffectOutcomeRow[]; - triggers: TriggerActionMetadataRow[]; + rule: ConditionalEffectRuleRow; + statePools: ConditionalEffectStatePoolRow[]; + clauses: ConditionalEffectClauseRow[]; + outcomes: ConditionalEffectOutcomeRow[]; + triggers: TriggerActionMetadataRow[]; }; export type MechanicPersistenceOptions = { - name?: string; - variantKey?: string; + name?: string; + variantKey?: string; }; export type MechanicPersistenceStatus = - | 'READY' - | 'REQUIRES_ASSUMPTIONS' - | 'CAPTURED_ONLY' - | 'INVALID'; + 'READY' | 'REQUIRES_ASSUMPTIONS' | 'CAPTURED_ONLY' | 'INVALID'; export type MechanicPersistenceResult = { - status: MechanicPersistenceStatus; - simulationReadiness: 'ready' | 'requires_assumptions' | 'captured_only' | null; - program: MechanicProgram | null; - errors: string[]; - warnings: string[]; + status: MechanicPersistenceStatus; + simulationReadiness: + 'ready' | 'requires_assumptions' | 'captured_only' | null; + program: MechanicProgram | null; + errors: string[]; + warnings: string[]; }; type ParsedJson = { ok: true; value: unknown } | { ok: false }; const eventKinds = new Set(MECHANIC_EVENT_KINDS); const object = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value); + typeof value === 'object' && value !== null && !Array.isArray(value); const finite = (value: unknown): value is number => - typeof value === 'number' && Number.isFinite(value); -const text = (value: DatabaseValue) => typeof value === 'string' ? value.trim() : ''; + typeof value === 'number' && Number.isFinite(value); +const text = (value: DatabaseValue) => + typeof value === 'string' ? value.trim() : ''; const nullableText = (value: DatabaseValue) => { - const result = text(value); - return result || null; + const result = text(value); + return result || null; }; const number = (value: DatabaseValue) => { - if (typeof value === 'bigint') return Number(value); - if (typeof value === 'boolean' || value === null || value === undefined || value === '') return null; - const result = Number(value); - return Number.isFinite(result) ? result : null; + if (typeof value === 'bigint') return Number(value); + if ( + typeof value === 'boolean' || + value === null || + value === undefined || + value === '' + ) + return null; + const result = Number(value); + return Number.isFinite(result) ? result : null; }; const enabled = (value: DatabaseValue) => - value === true || value === 1 || value === 1n || value === '1'; + value === true || value === 1 || value === 1n || value === '1'; -function parseJson(value: DatabaseValue, path: string, errors: string[]): ParsedJson { - if (typeof value !== 'string') { - errors.push(`${path} must contain JSON text.`); - return { ok: false }; - } - try { - return { ok: true, value: JSON.parse(value) }; - } catch { - errors.push(`${path} contains malformed JSON.`); - return { ok: false }; - } +function parseJson( + value: DatabaseValue, + path: string, + errors: string[], +): ParsedJson { + if (typeof value !== 'string') { + errors.push(`${path} must contain JSON text.`); + return { ok: false }; + } + try { + return { ok: true, value: JSON.parse(value) }; + } catch { + errors.push(`${path} contains malformed JSON.`); + return { ok: false }; + } } function requiredText(value: DatabaseValue, path: string, errors: string[]) { - const result = text(value); - if (!result) errors.push(`${path} is required.`); - return result; + const result = text(value); + if (!result) errors.push(`${path} is required.`); + return result; } function requiredNumber(value: DatabaseValue, path: string, errors: string[]) { - const result = number(value); - if (result === null) errors.push(`${path} must be a finite number.`); - return result ?? 0; + const result = number(value); + if (result === null) errors.push(`${path} must be a finite number.`); + return result ?? 0; } function isEntitySelector(value: unknown) { - if (!object(value) || typeof value.kind !== 'string') return false; - if (value.kind === 'agent' || value.kind === 'event_actor' || value.kind === 'event_target') { - return true; - } - return value.kind === 'event_field' - && typeof value.path === 'string' - && Boolean(value.path.trim()); + if (!object(value) || typeof value.kind !== 'string') return false; + if ( + value.kind === 'agent' || + value.kind === 'event_actor' || + value.kind === 'event_target' + ) { + return true; + } + return ( + value.kind === 'event_field' && + typeof value.path === 'string' && + Boolean(value.path.trim()) + ); } function isValueReference(value: unknown) { - if (!object(value) || typeof value.source !== 'string') return false; - if (value.source === 'event' || value.source === 'equipment') { - return typeof value.path === 'string' && Boolean(value.path.trim()); - } - if (value.source === 'combat') { - return typeof value.path === 'string' - && Boolean(value.path.trim()) - && (value.timing === undefined || value.timing === 'pre_event' || value.timing === 'current') - && !(value.path === 'currentHealthPercent' && value.timing === 'pre_event'); - } - if (value.source === 'attribute') { - return typeof value.attributeId === 'string' && Boolean(value.attributeId.trim()); - } - if (value.source === 'scenario') { - return typeof value.key === 'string' && Boolean(value.key.trim()); - } - if (value.source === 'reducer') return true; - return value.source === 'pool' - && typeof value.pool === 'string' - && Boolean(value.pool.trim()) - && (value.entity === undefined || isEntitySelector(value.entity)); + if (!object(value) || typeof value.source !== 'string') return false; + if (value.source === 'event' || value.source === 'equipment') { + return typeof value.path === 'string' && Boolean(value.path.trim()); + } + if (value.source === 'combat') { + return ( + typeof value.path === 'string' && + Boolean(value.path.trim()) && + (value.timing === undefined || + value.timing === 'pre_event' || + value.timing === 'current') && + !(value.path === 'currentHealthPercent' && value.timing === 'pre_event') + ); + } + if (value.source === 'attribute') { + return ( + typeof value.attributeId === 'string' && Boolean(value.attributeId.trim()) + ); + } + if (value.source === 'scenario') { + return typeof value.key === 'string' && Boolean(value.key.trim()); + } + if (value.source === 'reducer') return true; + return ( + value.source === 'pool' && + typeof value.pool === 'string' && + Boolean(value.pool.trim()) && + (value.entity === undefined || isEntitySelector(value.entity)) + ); } function isNumericExpression(value: unknown): value is NumericExpression { - if (finite(value)) return true; - if (!object(value)) return false; - if ('ref' in value) return isValueReference(value.ref); - if (value.op === 'floor') return isNumericExpression(value.value); - if (value.op === 'clamp') { - return isNumericExpression(value.value) - && isNumericExpression(value.min) - && isNumericExpression(value.max); - } - return ['add', 'subtract', 'multiply', 'divide', 'min', 'max'].includes(String(value.op)) - && isNumericExpression(value.left) - && isNumericExpression(value.right); + if (finite(value)) return true; + if (!object(value)) return false; + if ('ref' in value) return isValueReference(value.ref); + if (value.op === 'floor') return isNumericExpression(value.value); + if (value.op === 'clamp') { + return ( + isNumericExpression(value.value) && + isNumericExpression(value.min) && + isNumericExpression(value.max) + ); + } + return ( + ['add', 'subtract', 'multiply', 'divide', 'min', 'max'].includes( + String(value.op), + ) && + isNumericExpression(value.left) && + isNumericExpression(value.right) + ); } function isPredicate(value: unknown): value is Predicate { - if (!object(value) || typeof value.kind !== 'string') return false; - if (value.kind === 'true') return true; - if (value.kind === 'exists') return object(value.value) && typeof value.value.source === 'string'; - if (value.kind === 'not') return isPredicate(value.predicate); - if (value.kind === 'all' || value.kind === 'any') { - return Array.isArray(value.predicates) && value.predicates.every(isPredicate); - } - return value.kind === 'compare' - && ['eq', 'ne', 'gt', 'gte', 'lt', 'lte'].includes(String(value.operator)) - && 'left' in value - && 'right' in value; + if (!object(value) || typeof value.kind !== 'string') return false; + if (value.kind === 'true') return true; + if (value.kind === 'exists') + return object(value.value) && typeof value.value.source === 'string'; + if (value.kind === 'not') return isPredicate(value.predicate); + if (value.kind === 'all' || value.kind === 'any') { + return ( + Array.isArray(value.predicates) && value.predicates.every(isPredicate) + ); + } + return ( + value.kind === 'compare' && + ['eq', 'ne', 'gt', 'gte', 'lt', 'lte'].includes(String(value.operator)) && + 'left' in value && + 'right' in value + ); } function isReducer(value: unknown): value is Reducer { - if (!object(value) || typeof value.kind !== 'string') return false; - if (value.kind === 'none') return true; - if (value.kind === 'count') return finite(value.threshold); - if (value.kind === 'modulo') return finite(value.every); - if (value.kind === 'distinct_count') { - return finite(value.threshold) - && finite(value.windowSeconds) - && object(value.distinctBy) - && typeof value.distinctBy.source === 'string'; - } - if (value.kind === 'consecutive_count') return finite(value.threshold); - if (value.kind === 'sliding_count') return finite(value.threshold) && finite(value.windowSeconds); - if (value.kind === 'duration') return finite(value.seconds); - if (value.kind === 'periodic') return isNumericExpression(value.intervalSeconds); - return value.kind === 'derived_scalar' && isNumericExpression(value.value); + if (!object(value) || typeof value.kind !== 'string') return false; + if (value.kind === 'none') return true; + if (value.kind === 'count') return finite(value.threshold); + if (value.kind === 'modulo') return finite(value.every); + if (value.kind === 'distinct_count') { + return ( + finite(value.threshold) && + finite(value.windowSeconds) && + object(value.distinctBy) && + typeof value.distinctBy.source === 'string' + ); + } + if (value.kind === 'consecutive_count') return finite(value.threshold); + if (value.kind === 'sliding_count') + return finite(value.threshold) && finite(value.windowSeconds); + if (value.kind === 'duration') return finite(value.seconds); + if (value.kind === 'periodic') + return isNumericExpression(value.intervalSeconds); + return value.kind === 'derived_scalar' && isNumericExpression(value.value); } function isMutation(value: unknown): value is ClauseMutation { - if (!object(value) || typeof value.kind !== 'string') return false; - if (value.kind === 'none') return true; - if (value.kind === 'clear') { - return typeof value.pool === 'string' - && (value.entity === undefined || isEntitySelector(value.entity)); - } - if (value.kind === 'transfer') { - return typeof value.pool === 'string' - && isEntitySelector(value.from) - && isEntitySelector(value.to) - && (value.value === undefined || value.value === 'all' || isNumericExpression(value.value)); - } - return ['add', 'set', 'consume', 'derive'].includes(value.kind) - && typeof value.pool === 'string' - && isNumericExpression(value.value) - && (value.entity === undefined || isEntitySelector(value.entity)); + if (!object(value) || typeof value.kind !== 'string') return false; + if (value.kind === 'none') return true; + if (value.kind === 'clear') { + return ( + typeof value.pool === 'string' && + (value.entity === undefined || isEntitySelector(value.entity)) + ); + } + if (value.kind === 'transfer') { + return ( + typeof value.pool === 'string' && + isEntitySelector(value.from) && + isEntitySelector(value.to) && + (value.value === undefined || + value.value === 'all' || + isNumericExpression(value.value)) + ); + } + return ( + ['add', 'set', 'consume', 'derive'].includes(value.kind) && + typeof value.pool === 'string' && + isNumericExpression(value.value) && + (value.entity === undefined || isEntitySelector(value.entity)) + ); } function parseScenarioInputs( - raw: DatabaseValue, - errors: string[] + raw: DatabaseValue, + errors: string[], ): ScenarioInputDefinition[] { - const parsed = parseJson(raw, 'Rule scenario inputs', errors); - if (!parsed.ok) return []; - if (!Array.isArray(parsed.value)) { - errors.push('Rule scenario inputs must be a JSON array.'); - return []; - } - const inputs: ScenarioInputDefinition[] = []; - for (const [index, candidate] of parsed.value.entries()) { - const path = `Scenario input ${index + 1}`; - if (!object(candidate)) { - errors.push(`${path} must be an object.`); - continue; - } - if ( - typeof candidate.key !== 'string' - || typeof candidate.label !== 'string' - || !['toggle', 'slider', 'number'].includes(String(candidate.kind)) - ) { - errors.push(`${path} requires a key, label, and supported input kind.`); - continue; - } - inputs.push(candidate as ScenarioInputDefinition); - } - return inputs; + const parsed = parseJson(raw, 'Rule scenario inputs', errors); + if (!parsed.ok) return []; + if (!Array.isArray(parsed.value)) { + errors.push('Rule scenario inputs must be a JSON array.'); + return []; + } + const inputs: ScenarioInputDefinition[] = []; + for (const [index, candidate] of parsed.value.entries()) { + const path = `Scenario input ${index + 1}`; + if (!object(candidate)) { + errors.push(`${path} must be an object.`); + continue; + } + if ( + typeof candidate.key !== 'string' || + typeof candidate.label !== 'string' || + !['toggle', 'slider', 'number'].includes(String(candidate.kind)) + ) { + errors.push(`${path} requires a key, label, and supported input kind.`); + continue; + } + inputs.push(candidate as ScenarioInputDefinition); + } + return inputs; } type LegacyMapping = { - when: MechanicEventKind; - predicate?: Predicate; - reducer?: Reducer; + when: MechanicEventKind; + predicate?: Predicate; + reducer?: Reducer; }; function legacyActionMapping( - action: string, - params: Record, - path: string, - errors: string[] + action: string, + params: Record, + path: string, + errors: string[], ): LegacyMapping | null { - const eventValue = (field: string) => ({ source: 'event' as const, path: `values.${field}` }); - const targetExists: Predicate = { - kind: 'exists', - value: { source: 'event', path: 'targetId' } - }; - switch (action) { - case 'same_target_hits_within_window': { - const hits = params.hitsRequired; - const window = params.windowSeconds; - if (!Number.isInteger(hits) || Number(hits) <= 0 || !finite(window) || window <= 0) { - errors.push(`${path} has invalid same-target hit parameters.`); - return null; - } - return { - when: 'weapon_hit', - predicate: targetExists, - reducer: { - kind: 'sliding_count', - threshold: Number(hits), - windowSeconds: window, - reset: 'zero', - groupBy: { source: 'event', path: 'targetId' } - } - }; - } - case 'headshot': - return { - when: 'weapon_hit', - predicate: { - kind: 'compare', - left: eventValue('headshot'), - operator: 'eq', - right: true - } - }; - case 'headshot_kill': - return { - when: 'enemy_killed', - predicate: { - kind: 'compare', - left: eventValue('headshot'), - operator: 'eq', - right: true - } - }; - case 'weapon_kill': - return { when: 'enemy_killed' }; - case 'target_hit_within_distance': { - const meters = params.meters; - if (!finite(meters) || meters <= 0) { - errors.push(`${path} has an invalid distance parameter.`); - return null; - } - return { - when: 'weapon_hit', - predicate: { - kind: 'all', - predicates: [ - targetExists, - { - kind: 'compare', - left: eventValue('distanceMeters'), - operator: 'lte', - right: meters - } - ] - } - }; - } - case 'damage_taken': - return { when: 'health_damage_taken' }; - case 'weapon_swap': - return { when: 'weapon_swapped' }; - case 'reload': - return { when: 'reload_started' }; - default: - errors.push( - `${path} uses legacy action "${action}", which cannot be represented exactly by the mechanic runtime.` - ); - return null; - } + const eventValue = (field: string) => ({ + source: 'event' as const, + path: `values.${field}`, + }); + const targetExists: Predicate = { + kind: 'exists', + value: { source: 'event', path: 'targetId' }, + }; + switch (action) { + case 'same_target_hits_within_window': { + const hits = params.hitsRequired; + const window = params.windowSeconds; + if ( + !Number.isInteger(hits) || + Number(hits) <= 0 || + !finite(window) || + window <= 0 + ) { + errors.push(`${path} has invalid same-target hit parameters.`); + return null; + } + return { + when: 'weapon_hit', + predicate: targetExists, + reducer: { + kind: 'sliding_count', + threshold: Number(hits), + windowSeconds: window, + reset: 'zero', + groupBy: { source: 'event', path: 'targetId' }, + }, + }; + } + case 'headshot': + return { + when: 'weapon_hit', + predicate: { + kind: 'compare', + left: eventValue('headshot'), + operator: 'eq', + right: true, + }, + }; + case 'headshot_kill': + return { + when: 'enemy_killed', + predicate: { + kind: 'compare', + left: eventValue('headshot'), + operator: 'eq', + right: true, + }, + }; + case 'weapon_kill': + return { when: 'enemy_killed' }; + case 'target_hit_within_distance': { + const meters = params.meters; + if (!finite(meters) || meters <= 0) { + errors.push(`${path} has an invalid distance parameter.`); + return null; + } + return { + when: 'weapon_hit', + predicate: { + kind: 'all', + predicates: [ + targetExists, + { + kind: 'compare', + left: eventValue('distanceMeters'), + operator: 'lte', + right: meters, + }, + ], + }, + }; + } + case 'damage_taken': + return { when: 'health_damage_taken' }; + case 'weapon_swap': + return { when: 'weapon_swapped' }; + case 'reload': + return { when: 'reload_started' }; + default: + errors.push( + `${path} uses legacy action "${action}", which cannot be represented exactly by the mechanic runtime.`, + ); + return null; + } } -function combinePredicates(left: Predicate | undefined, right: Predicate | undefined) { - if (!left || left.kind === 'true') return right; - if (!right || right.kind === 'true') return left; - return { kind: 'all' as const, predicates: [left, right] }; +function combinePredicates( + left: Predicate | undefined, + right: Predicate | undefined, +) { + if (!left || left.kind === 'true') return right; + if (!right || right.kind === 'true') return left; + return { kind: 'all' as const, predicates: [left, right] }; } function resolveClauseMapping( - trigger: TriggerActionMetadataRow | null, - eventKind: DatabaseValue, - parameters: Record, - explicitPredicate: Predicate, - explicitReducer: Reducer, - path: string, - errors: string[] + trigger: TriggerActionMetadataRow | null, + eventKind: DatabaseValue, + parameters: Record, + explicitPredicate: Predicate, + explicitReducer: Reducer, + path: string, + errors: string[], ) { - const directEvent = nullableText(eventKind) - ?? nullableText(trigger?.event_kind) - ?? (eventKinds.has(text(trigger?.action_key)) ? text(trigger?.action_key) : null); - if (directEvent) { - return { - when: directEvent as MechanicEventKind, - predicate: explicitPredicate, - reducer: explicitReducer - }; - } - if (!trigger) { - errors.push(`${path} requires a direct event or known Action Family recipe.`); - return null; - } - const legacy = legacyActionMapping(text(trigger.action_key), parameters, path, errors); - if (!legacy) return null; - return { - when: legacy.when, - predicate: combinePredicates(legacy.predicate, explicitPredicate), - reducer: explicitReducer.kind === 'none' - ? legacy.reducer ?? explicitReducer - : explicitReducer - }; + const directEvent = + nullableText(eventKind) ?? + nullableText(trigger?.event_kind) ?? + (eventKinds.has(text(trigger?.action_key)) + ? text(trigger?.action_key) + : null); + if (directEvent) { + return { + when: directEvent as MechanicEventKind, + predicate: explicitPredicate, + reducer: explicitReducer, + }; + } + if (!trigger) { + errors.push( + `${path} requires a direct event or known Action Family recipe.`, + ); + return null; + } + const legacy = legacyActionMapping( + text(trigger.action_key), + parameters, + path, + errors, + ); + if (!legacy) return null; + return { + when: legacy.when, + predicate: combinePredicates(legacy.predicate, explicitPredicate), + reducer: + explicitReducer.kind === 'none' + ? (legacy.reducer ?? explicitReducer) + : explicitReducer, + }; } function parsePool( - row: ConditionalEffectStatePoolRow, - ruleId: string, - index: number, - selectedVariant: string, - errors: string[] + row: ConditionalEffectStatePoolRow, + ruleId: string, + index: number, + selectedVariant: string, + errors: string[], ): { id: string; definition: PoolDefinition } | null { - const path = `State pool ${index + 1}`; - if (text(row.rule_id) !== ruleId) { - errors.push(`${path} belongs to a different rule.`); - return null; - } - const id = requiredText(row.id, `${path} ID`, errors); - const key = requiredText(row.state_key, `${path} key`, errors); - const label = requiredText(row.label, `${path} label`, errors); - const scope = text(row.state_scope); - if (scope !== 'agent' && scope !== 'target') errors.push(`${path} has an invalid scope.`); - const initial = requiredNumber(row.initial_value, `${path} initial value`, errors); - const minimum = number(row.minimum_value); - const maximum = requiredNumber(row.maximum_value, `${path} maximum value`, errors); - const bestCase = number(row.best_case_value); - const duration = number(row.duration_seconds); - const parameters = parseJson(row.parameters_json, `${path} parameters`, errors); - if (parameters.ok && !object(parameters.value)) errors.push(`${path} parameters must be a JSON object.`); - const durationVariants = parameters.ok && object(parameters.value) - && object(parameters.value.expiresAfterSecondsVariants) - ? parameters.value.expiresAfterSecondsVariants - : null; - const variantDuration = durationVariants ? number(durationVariants[selectedVariant] as DatabaseValue) : null; - if (durationVariants && variantDuration === null) { - errors.push(`${path} has no valid expiration duration for variant "${selectedVariant}".`); - } - return { - id, - definition: { - key, - label, - scope: scope === 'target' ? 'target' : 'agent', - initial, - ...(minimum === null ? {} : { min: minimum }), - max: maximum, - ...(bestCase === null ? {} : { bestCaseValue: bestCase }), - ...(variantDuration !== null - ? { expiresAfterSeconds: variantDuration } - : duration === null ? {} : { expiresAfterSeconds: duration }) - } - }; + const path = `State pool ${index + 1}`; + if (text(row.rule_id) !== ruleId) { + errors.push(`${path} belongs to a different rule.`); + return null; + } + const id = requiredText(row.id, `${path} ID`, errors); + const key = requiredText(row.state_key, `${path} key`, errors); + const label = requiredText(row.label, `${path} label`, errors); + const scope = text(row.state_scope); + if (scope !== 'agent' && scope !== 'target') + errors.push(`${path} has an invalid scope.`); + const initial = requiredNumber( + row.initial_value, + `${path} initial value`, + errors, + ); + const minimum = number(row.minimum_value); + const maximum = requiredNumber( + row.maximum_value, + `${path} maximum value`, + errors, + ); + const bestCase = number(row.best_case_value); + const duration = number(row.duration_seconds); + const parameters = parseJson( + row.parameters_json, + `${path} parameters`, + errors, + ); + if (parameters.ok && !object(parameters.value)) + errors.push(`${path} parameters must be a JSON object.`); + const durationVariants = + parameters.ok && + object(parameters.value) && + object(parameters.value.expiresAfterSecondsVariants) + ? parameters.value.expiresAfterSecondsVariants + : null; + const variantDuration = durationVariants + ? number(durationVariants[selectedVariant] as DatabaseValue) + : null; + if (durationVariants && variantDuration === null) { + errors.push( + `${path} has no valid expiration duration for variant "${selectedVariant}".`, + ); + } + return { + id, + definition: { + key, + label, + scope: scope === 'target' ? 'target' : 'agent', + initial, + ...(minimum === null ? {} : { min: minimum }), + max: maximum, + ...(bestCase === null ? {} : { bestCaseValue: bestCase }), + ...(variantDuration !== null + ? { expiresAfterSeconds: variantDuration } + : duration === null + ? {} + : { expiresAfterSeconds: duration }), + }, + }; } function outcomeKind(value: string): MechanicOutcome['kind'] | null { - if (value === 'active_modifier') return 'attribute_modifier'; - if (value === 'direct_damage') return 'direct_damage'; - if ( - value === 'restoration' - || ['restore_health', 'restore_armor', 'restore_ammo', 'restore_signature_ability_charge'].includes(value) - ) { - return 'restoration'; - } - if (value === 'resource_cost') return 'resource_cost'; - if (value === 'weapon_reload') return 'weapon_reload'; - if (value === 'marker') return 'marker'; - return null; + if (value === 'active_modifier') return 'attribute_modifier'; + if (value === 'direct_damage') return 'direct_damage'; + if ( + value === 'restoration' || + [ + 'restore_health', + 'restore_armor', + 'restore_ammo', + 'restore_signature_ability_charge', + ].includes(value) + ) { + return 'restoration'; + } + if (value === 'resource_cost') return 'resource_cost'; + if (value === 'weapon_reload') return 'weapon_reload'; + if (value === 'marker') return 'marker'; + return null; } function outcomeScope(value: string): MechanicOutcome['scope'] | null { - if (value === 'self') return 'self'; - if (value === 'equipped_weapon') return 'equipped_weapon'; - if (value === 'inactive_weapon') return 'inactive_weapon'; - if (value === 'target') return 'target'; - if (value === 'allies') return 'allies'; - if (value === 'self_and_allies') return 'self_and_allies'; - if (value === 'assigned_ability') return 'assigned_ability'; - if (value === 'all_skills') return 'all_skills'; - return null; + if (value === 'self') return 'self'; + if (value === 'equipped_weapon') return 'equipped_weapon'; + if (value === 'inactive_weapon') return 'inactive_weapon'; + if (value === 'target') return 'target'; + if (value === 'allies') return 'allies'; + if (value === 'self_and_allies') return 'self_and_allies'; + if (value === 'assigned_ability') return 'assigned_ability'; + if (value === 'all_skills') return 'all_skills'; + return null; } function parseOutcome( - row: ConditionalEffectOutcomeRow, - ruleId: string, - poolKeys: Map, - index: number, - errors: string[] + row: ConditionalEffectOutcomeRow, + ruleId: string, + poolKeys: Map, + index: number, + errors: string[], ): { clauseId: string | null; outcome: MechanicOutcome } | null { - const path = `Outcome ${index + 1}`; - if (text(row.rule_id) !== ruleId) { - errors.push(`${path} belongs to a different rule.`); - return null; - } - const id = requiredText(row.id, `${path} ID`, errors); - const label = requiredText(row.effect_key, `${path} effect key`, errors); - const kind = outcomeKind(text(row.application_kind)); - if (!kind) errors.push(`${path} has an unsupported application kind.`); - const scope = outcomeScope(text(row.effect_scope)); - if (!scope) { - errors.push(`${path} has an effect scope that cannot be represented without losing meaning.`); - } - const basis = text(row.value_basis); - if (!['flat', 'max_health_percent', 'current_health_percent', 'weapon_base_percent'].includes(basis)) { - errors.push(`${path} has an unsupported value basis.`); - } - const phase = text(row.application_phase); - if (!['while_active', 'on_event', 'after_event', 'on_enter', 'on_exit'].includes(phase)) { - errors.push(`${path} has an unsupported application phase.`); - } - let value: NumericExpression = requiredNumber(row.value, `${path} value`, errors); - if (row.value_expression_json !== null && row.value_expression_json !== undefined && row.value_expression_json !== '') { - const parsed = parseJson(row.value_expression_json, `${path} value expression`, errors); - if (parsed.ok) { - if (isNumericExpression(parsed.value)) value = parsed.value; - else errors.push(`${path} value expression is not a valid numeric expression.`); - } - } - const statePoolId = nullableText(row.state_pool_id); - const statePool = statePoolId ? poolKeys.get(statePoolId) : undefined; - if (statePoolId && !statePool) errors.push(`${path} references an unknown state pool.`); - const scaling = text(row.value_scaling); - if (scaling !== 'flat' && scaling !== 'per_state_unit') { - errors.push(`${path} has an unsupported value-scaling mode.`); - } - if (!kind || !scope) return null; - const legacyResource = text(row.application_kind) === 'restore_health' - ? 'health' - : text(row.application_kind) === 'restore_armor' - ? 'armor' - : text(row.application_kind) === 'restore_ammo' - ? 'ammo' - : text(row.application_kind) === 'restore_signature_ability_charge' - ? 'signature_ability_charge' - : text(row.application_kind) === 'resource_cost' - ? 'health' - : null; - const resource = nullableText(row.resource_kind) ?? legacyResource; - if ( - (kind === 'restoration' || kind === 'resource_cost') - && !['health', 'armor', 'ammo', 'signature_ability_charge'].includes(String(resource)) - ) { - errors.push(`${path} requires an explicit supported resource.`); - } - const combatTiming = nullableText(row.combat_timing); - if (combatTiming && combatTiming !== 'pre_event' && combatTiming !== 'current') { - errors.push(`${path} has invalid combat timing.`); - } - const delivery = nullableText(row.delivery_mode) ?? 'instant'; - if (delivery !== 'instant' && delivery !== 'continuous') { - errors.push(`${path} has an unsupported delivery mode.`); - } - const durationSeconds = number(row.delivery_duration_seconds); - if (delivery === 'continuous' && (durationSeconds === null || durationSeconds <= 0)) { - errors.push(`${path} continuous delivery requires a positive duration.`); - } - if (delivery === 'instant' && durationSeconds !== null) { - errors.push(`${path} instant delivery cannot set a duration.`); - } - let targeting: MechanicOutcome['targeting'] | undefined; - if (row.targeting_json !== null && row.targeting_json !== undefined && row.targeting_json !== '') { - const parsed = parseJson(row.targeting_json, `${path} targeting`, errors); - if (parsed.ok && typeof parsed.value === 'object' && parsed.value !== null) { - targeting = parsed.value as MechanicOutcome['targeting']; - } - } - return { - clauseId: nullableText(row.clause_id), - outcome: { - id, - label, - phase: phase as OutcomePhase, - kind, - scope, - value, - basis: basis as OutcomeBasis, - ...(resource && ['health', 'armor', 'ammo'].includes(resource) - ? { resource: resource as 'health' | 'armor' | 'ammo' } - : {}), - ...(combatTiming === 'pre_event' || combatTiming === 'current' - ? { combatTiming } - : {}), - ...(delivery === 'continuous' && durationSeconds !== null - ? { delivery, durationSeconds } - : {}), - ...(targeting ? { targeting } : {}), - ...(kind === 'attribute_modifier' && nullableText(row.stat_attribute_id) - ? { attributeId: text(row.stat_attribute_id) } - : {}), - ...(statePool ? { statePool } : {}), - ...(scaling === 'per_state_unit' ? { perStack: true } : {}), - ...(number(row.min_state_value) === null ? {} : { minStacks: number(row.min_state_value) as number }), - ...(number(row.max_state_value) === null ? {} : { maxStacks: number(row.max_state_value) as number }) - } - }; + const path = `Outcome ${index + 1}`; + if (text(row.rule_id) !== ruleId) { + errors.push(`${path} belongs to a different rule.`); + return null; + } + const id = requiredText(row.id, `${path} ID`, errors); + const label = requiredText(row.effect_key, `${path} effect key`, errors); + const kind = outcomeKind(text(row.application_kind)); + if (!kind) errors.push(`${path} has an unsupported application kind.`); + const scope = outcomeScope(text(row.effect_scope)); + if (!scope) { + errors.push( + `${path} has an effect scope that cannot be represented without losing meaning.`, + ); + } + const basis = text(row.value_basis); + if ( + ![ + 'flat', + 'max_health_percent', + 'current_health_percent', + 'weapon_base_percent', + ].includes(basis) + ) { + errors.push(`${path} has an unsupported value basis.`); + } + const phase = text(row.application_phase); + if ( + ![ + 'while_active', + 'on_event', + 'after_event', + 'on_enter', + 'on_exit', + ].includes(phase) + ) { + errors.push(`${path} has an unsupported application phase.`); + } + let value: NumericExpression = requiredNumber( + row.value, + `${path} value`, + errors, + ); + if ( + row.value_expression_json !== null && + row.value_expression_json !== undefined && + row.value_expression_json !== '' + ) { + const parsed = parseJson( + row.value_expression_json, + `${path} value expression`, + errors, + ); + if (parsed.ok) { + if (isNumericExpression(parsed.value)) value = parsed.value; + else + errors.push( + `${path} value expression is not a valid numeric expression.`, + ); + } + } + const statePoolId = nullableText(row.state_pool_id); + const statePool = statePoolId ? poolKeys.get(statePoolId) : undefined; + if (statePoolId && !statePool) + errors.push(`${path} references an unknown state pool.`); + const scaling = text(row.value_scaling); + if (scaling !== 'flat' && scaling !== 'per_state_unit') { + errors.push(`${path} has an unsupported value-scaling mode.`); + } + if (!kind || !scope) return null; + const legacyResource = + text(row.application_kind) === 'restore_health' + ? 'health' + : text(row.application_kind) === 'restore_armor' + ? 'armor' + : text(row.application_kind) === 'restore_ammo' + ? 'ammo' + : text(row.application_kind) === 'restore_signature_ability_charge' + ? 'signature_ability_charge' + : text(row.application_kind) === 'resource_cost' + ? 'health' + : null; + const resource = nullableText(row.resource_kind) ?? legacyResource; + if ( + (kind === 'restoration' || kind === 'resource_cost') && + !['health', 'armor', 'ammo', 'signature_ability_charge'].includes( + String(resource), + ) + ) { + errors.push(`${path} requires an explicit supported resource.`); + } + const combatTiming = nullableText(row.combat_timing); + if ( + combatTiming && + combatTiming !== 'pre_event' && + combatTiming !== 'current' + ) { + errors.push(`${path} has invalid combat timing.`); + } + const delivery = nullableText(row.delivery_mode) ?? 'instant'; + if (delivery !== 'instant' && delivery !== 'continuous') { + errors.push(`${path} has an unsupported delivery mode.`); + } + const durationSeconds = number(row.delivery_duration_seconds); + if ( + delivery === 'continuous' && + (durationSeconds === null || durationSeconds <= 0) + ) { + errors.push(`${path} continuous delivery requires a positive duration.`); + } + if (delivery === 'instant' && durationSeconds !== null) { + errors.push(`${path} instant delivery cannot set a duration.`); + } + let targeting: MechanicOutcome['targeting'] | undefined; + if ( + row.targeting_json !== null && + row.targeting_json !== undefined && + row.targeting_json !== '' + ) { + const parsed = parseJson(row.targeting_json, `${path} targeting`, errors); + if ( + parsed.ok && + typeof parsed.value === 'object' && + parsed.value !== null + ) { + targeting = parsed.value as MechanicOutcome['targeting']; + } + } + return { + clauseId: nullableText(row.clause_id), + outcome: { + id, + label, + phase: phase as OutcomePhase, + kind, + scope, + value, + basis: basis as OutcomeBasis, + ...(resource && ['health', 'armor', 'ammo'].includes(resource) + ? { resource: resource as 'health' | 'armor' | 'ammo' } + : {}), + ...(combatTiming === 'pre_event' || combatTiming === 'current' + ? { combatTiming } + : {}), + ...(delivery === 'continuous' && durationSeconds !== null + ? { delivery, durationSeconds } + : {}), + ...(targeting ? { targeting } : {}), + ...(kind === 'attribute_modifier' && nullableText(row.stat_attribute_id) + ? { attributeId: text(row.stat_attribute_id) } + : {}), + ...(statePool ? { statePool } : {}), + ...(scaling === 'per_state_unit' ? { perStack: true } : {}), + ...(number(row.min_state_value) === null + ? {} + : { minStacks: number(row.min_state_value) as number }), + ...(number(row.max_state_value) === null + ? {} + : { maxStacks: number(row.max_state_value) as number }), + }, + }; } /** @@ -637,194 +787,283 @@ function parseOutcome( * throws for row or JSON validation failures. */ export function adaptConditionalEffectProgram( - input: MechanicPersistenceInput, - options: MechanicPersistenceOptions = {} + input: MechanicPersistenceInput, + options: MechanicPersistenceOptions = {}, ): MechanicPersistenceResult { - const errors: string[] = []; - const warnings: string[] = []; - const readiness = text(input.rule.simulation_readiness); - const simulationReadiness = ['ready', 'requires_assumptions', 'captured_only'].includes(readiness) - ? readiness as MechanicPersistenceResult['simulationReadiness'] - : null; - if (!simulationReadiness) errors.push('Rule simulation readiness is invalid.'); - const ruleId = requiredText(input.rule.id, 'Rule ID', errors); - const sourceType = requiredText(input.rule.source_type, 'Rule source type', errors); - const sourceId = requiredText(input.rule.source_id, 'Rule source ID', errors); - const scriptVersion = requiredNumber(input.rule.script_version, 'Rule script version', errors); - if (!Number.isInteger(scriptVersion) || scriptVersion <= 0) { - errors.push('Rule script version must be a positive integer.'); - } - const scenarioInputs = parseScenarioInputs(input.rule.scenario_inputs_json, errors); + const errors: string[] = []; + const warnings: string[] = []; + const readiness = text(input.rule.simulation_readiness); + const simulationReadiness = [ + 'ready', + 'requires_assumptions', + 'captured_only', + ].includes(readiness) + ? (readiness as MechanicPersistenceResult['simulationReadiness']) + : null; + if (!simulationReadiness) + errors.push('Rule simulation readiness is invalid.'); + const ruleId = requiredText(input.rule.id, 'Rule ID', errors); + const sourceType = requiredText( + input.rule.source_type, + 'Rule source type', + errors, + ); + const sourceId = requiredText(input.rule.source_id, 'Rule source ID', errors); + const scriptVersion = requiredNumber( + input.rule.script_version, + 'Rule script version', + errors, + ); + if (!Number.isInteger(scriptVersion) || scriptVersion <= 0) { + errors.push('Rule script version must be a positive integer.'); + } + const scenarioInputs = parseScenarioInputs( + input.rule.scenario_inputs_json, + errors, + ); - const variantKeys = [...new Set(input.outcomes.map((row) => text(row.variant_key)).filter(Boolean))]; - let selectedVariant = options.variantKey?.trim() || ''; - if (!selectedVariant) { - if (variantKeys.length > 1) { - errors.push(`A variant key is required because the rule has variants: ${variantKeys.join(', ')}.`); - } else { - selectedVariant = variantKeys[0] ?? 'base'; - } - } else if (variantKeys.length && !variantKeys.includes(selectedVariant)) { - errors.push(`Variant "${selectedVariant}" does not exist for this rule.`); - } - const parsedPools = input.statePools - .slice() - .sort((left, right) => (number(left.sort_order) ?? 0) - (number(right.sort_order) ?? 0)) - .map((row, index) => parsePool(row, ruleId, index, selectedVariant, errors)) - .filter((value): value is NonNullable => value !== null); - const poolKeys = new Map(parsedPools.map((pool) => [pool.id, pool.definition.key])); - const triggerMap = new Map( - input.triggers.map((trigger) => [text(trigger.id), trigger]) - ); + const variantKeys = [ + ...new Set( + input.outcomes.map((row) => text(row.variant_key)).filter(Boolean), + ), + ]; + let selectedVariant = options.variantKey?.trim() || ''; + if (!selectedVariant) { + if (variantKeys.length > 1) { + errors.push( + `A variant key is required because the rule has variants: ${variantKeys.join(', ')}.`, + ); + } else { + selectedVariant = variantKeys[0] ?? 'base'; + } + } else if (variantKeys.length && !variantKeys.includes(selectedVariant)) { + errors.push(`Variant "${selectedVariant}" does not exist for this rule.`); + } + const parsedPools = input.statePools + .slice() + .sort( + (left, right) => + (number(left.sort_order) ?? 0) - (number(right.sort_order) ?? 0), + ) + .map((row, index) => parsePool(row, ruleId, index, selectedVariant, errors)) + .filter((value): value is NonNullable => value !== null); + const poolKeys = new Map( + parsedPools.map((pool) => [pool.id, pool.definition.key]), + ); + const triggerMap = new Map( + input.triggers.map((trigger) => [text(trigger.id), trigger]), + ); - const selectedOutcomes = input.outcomes - .filter((row) => text(row.variant_key) === selectedVariant) - .slice() - .sort((left, right) => (number(left.sort_order) ?? 0) - (number(right.sort_order) ?? 0)) - .map((row, index) => parseOutcome(row, ruleId, poolKeys, index, errors)) - .filter((value): value is NonNullable => value !== null); - const outcomesByClause = new Map(); - const unassignedOutcomes: MechanicOutcome[] = []; - for (const parsed of selectedOutcomes) { - if (!parsed.clauseId) { - unassignedOutcomes.push(parsed.outcome); - continue; - } - const current = outcomesByClause.get(parsed.clauseId) ?? []; - current.push(parsed.outcome); - outcomesByClause.set(parsed.clauseId, current); - } + const selectedOutcomes = input.outcomes + .filter((row) => text(row.variant_key) === selectedVariant) + .slice() + .sort( + (left, right) => + (number(left.sort_order) ?? 0) - (number(right.sort_order) ?? 0), + ) + .map((row, index) => parseOutcome(row, ruleId, poolKeys, index, errors)) + .filter((value): value is NonNullable => value !== null); + const outcomesByClause = new Map(); + const unassignedOutcomes: MechanicOutcome[] = []; + for (const parsed of selectedOutcomes) { + if (!parsed.clauseId) { + unassignedOutcomes.push(parsed.outcome); + continue; + } + const current = outcomesByClause.get(parsed.clauseId) ?? []; + current.push(parsed.outcome); + outcomesByClause.set(parsed.clauseId, current); + } - const activeRows = input.clauses - .filter((row) => enabled(row.active)) - .slice() - .sort((left, right) => (number(left.sort_order) ?? 0) - (number(right.sort_order) ?? 0)); - if (unassignedOutcomes.length && activeRows.length !== 1) { - errors.push('Outcomes without a clause can only be adapted when the rule has exactly one active clause.'); - } - const clauses: MechanicClause[] = []; - for (const [index, row] of activeRows.entries()) { - const path = `Clause ${index + 1}`; - if (text(row.rule_id) !== ruleId) { - errors.push(`${path} belongs to a different rule.`); - continue; - } - const id = requiredText(row.id, `${path} ID`, errors); - const triggerId = nullableText(row.trigger_id); - const directEvent = nullableText(row.event_kind); - const trigger = triggerId ? triggerMap.get(triggerId) ?? null : null; - if (!directEvent && !trigger) { - errors.push(`${path} references unknown trigger "${triggerId}".`); - continue; - } - const parametersJson = parseJson(row.parameters_json, `${path} parameters`, errors); - const conditionsJson = parseJson(row.conditions_json, `${path} conditions`, errors); - const reducerJson = parseJson(row.reducer_json, `${path} reducer`, errors); - const mutationsJson = parseJson(row.mutations_json, `${path} mutations`, errors); - if (!parametersJson.ok || !conditionsJson.ok || !reducerJson.ok || !mutationsJson.ok) continue; - if (!object(parametersJson.value)) { - errors.push(`${path} parameters must be a JSON object.`); - continue; - } - if (!isPredicate(conditionsJson.value)) { - errors.push(`${path} conditions are not a valid predicate.`); - continue; - } - if (!isReducer(reducerJson.value)) { - errors.push(`${path} reducer is invalid.`); - continue; - } - if (!Array.isArray(mutationsJson.value) || !mutationsJson.value.every(isMutation)) { - errors.push(`${path} mutations must be an array of valid state mutations.`); - continue; - } - const mapping = resolveClauseMapping( - trigger, - row.event_kind, - parametersJson.value, - conditionsJson.value, - reducerJson.value, - path, - errors - ); - if (!mapping) continue; - const clauseOutcomes = outcomesByClause.get(id) ?? []; - if (activeRows.length === 1) clauseOutcomes.push(...unassignedOutcomes); - let weight: NumericExpression | undefined; - if ( - row.weight_expression_json !== null - && row.weight_expression_json !== undefined - && row.weight_expression_json !== '' - ) { - const parsedWeight = parseJson(row.weight_expression_json, `${path} weight`, errors); - if (parsedWeight.ok) { - if (isNumericExpression(parsedWeight.value)) weight = parsedWeight.value; - else errors.push(`${path} weight is not a valid numeric expression.`); - } - } - const recipeKey = nullableText(row.recipe_key); - clauses.push({ - id, - label: text(row.label) || text(row.clause_key) || undefined, - when: mapping.when, - ...(recipeKey ? { recipeKey } : {}), - ...(mapping.predicate && mapping.predicate.kind !== 'true' ? { if: mapping.predicate } : {}), - reducer: mapping.reducer, - ...(number(row.cooldown_seconds) === null - ? {} - : { cooldownSeconds: number(row.cooldown_seconds) as number }), - ...(weight === undefined ? {} : { weight }), - mutations: mutationsJson.value, - ...(clauseOutcomes.length ? { outcomes: clauseOutcomes } : {}) - }); - } - for (const clauseId of outcomesByClause.keys()) { - if (!activeRows.some((row) => text(row.id) === clauseId)) { - errors.push(`Outcomes reference missing or inactive clause "${clauseId}".`); - } - } + const activeRows = input.clauses + .filter((row) => enabled(row.active)) + .slice() + .sort( + (left, right) => + (number(left.sort_order) ?? 0) - (number(right.sort_order) ?? 0), + ); + if (unassignedOutcomes.length && activeRows.length !== 1) { + errors.push( + 'Outcomes without a clause can only be adapted when the rule has exactly one active clause.', + ); + } + const clauses: MechanicClause[] = []; + for (const [index, row] of activeRows.entries()) { + const path = `Clause ${index + 1}`; + if (text(row.rule_id) !== ruleId) { + errors.push(`${path} belongs to a different rule.`); + continue; + } + const id = requiredText(row.id, `${path} ID`, errors); + const triggerId = nullableText(row.trigger_id); + const directEvent = nullableText(row.event_kind); + const trigger = triggerId ? (triggerMap.get(triggerId) ?? null) : null; + if (!directEvent && !trigger) { + errors.push(`${path} references unknown trigger "${triggerId}".`); + continue; + } + const parametersJson = parseJson( + row.parameters_json, + `${path} parameters`, + errors, + ); + const conditionsJson = parseJson( + row.conditions_json, + `${path} conditions`, + errors, + ); + const reducerJson = parseJson(row.reducer_json, `${path} reducer`, errors); + const mutationsJson = parseJson( + row.mutations_json, + `${path} mutations`, + errors, + ); + if ( + !parametersJson.ok || + !conditionsJson.ok || + !reducerJson.ok || + !mutationsJson.ok + ) + continue; + if (!object(parametersJson.value)) { + errors.push(`${path} parameters must be a JSON object.`); + continue; + } + if (!isPredicate(conditionsJson.value)) { + errors.push(`${path} conditions are not a valid predicate.`); + continue; + } + if (!isReducer(reducerJson.value)) { + errors.push(`${path} reducer is invalid.`); + continue; + } + if ( + !Array.isArray(mutationsJson.value) || + !mutationsJson.value.every(isMutation) + ) { + errors.push( + `${path} mutations must be an array of valid state mutations.`, + ); + continue; + } + const mapping = resolveClauseMapping( + trigger, + row.event_kind, + parametersJson.value, + conditionsJson.value, + reducerJson.value, + path, + errors, + ); + if (!mapping) continue; + const clauseOutcomes = outcomesByClause.get(id) ?? []; + if (activeRows.length === 1) clauseOutcomes.push(...unassignedOutcomes); + let weight: NumericExpression | undefined; + if ( + row.weight_expression_json !== null && + row.weight_expression_json !== undefined && + row.weight_expression_json !== '' + ) { + const parsedWeight = parseJson( + row.weight_expression_json, + `${path} weight`, + errors, + ); + if (parsedWeight.ok) { + if (isNumericExpression(parsedWeight.value)) + weight = parsedWeight.value; + else errors.push(`${path} weight is not a valid numeric expression.`); + } + } + const recipeKey = nullableText(row.recipe_key); + clauses.push({ + id, + label: text(row.label) || text(row.clause_key) || undefined, + when: mapping.when, + ...(recipeKey ? { recipeKey } : {}), + ...(mapping.predicate && mapping.predicate.kind !== 'true' + ? { if: mapping.predicate } + : {}), + reducer: mapping.reducer, + ...(number(row.cooldown_seconds) === null + ? {} + : { cooldownSeconds: number(row.cooldown_seconds) as number }), + ...(weight === undefined ? {} : { weight }), + mutations: mutationsJson.value, + ...(clauseOutcomes.length ? { outcomes: clauseOutcomes } : {}), + }); + } + for (const clauseId of outcomesByClause.keys()) { + if (!activeRows.some((row) => text(row.id) === clauseId)) { + errors.push( + `Outcomes reference missing or inactive clause "${clauseId}".`, + ); + } + } - const program: MechanicProgram = { - id: ruleId, - name: options.name?.trim() || `${sourceType}:${sourceId}`, - ...(scenarioInputs.length ? { scenarioInputs } : {}), - ...(parsedPools.length ? { pools: parsedPools.map((pool) => pool.definition) } : {}), - clauses - }; - if (errors.length) { - return { status: 'INVALID', simulationReadiness, program: null, errors, warnings }; - } - try { - const validation = validateMechanicProgram(program); - if (!validation.ok) { - return { - status: 'INVALID', - simulationReadiness, - program: null, - errors: validation.errors, - warnings - }; - } - } catch { - return { - status: 'INVALID', - simulationReadiness, - program: null, - errors: ['The persisted mechanic contains a structure that the runtime cannot validate.'], - warnings - }; - } - if (!enabled(input.rule.configured) || simulationReadiness === 'captured_only') { - warnings.push('The mechanic is retained for configuration and is not ready for simulation.'); - return { status: 'CAPTURED_ONLY', simulationReadiness, program, errors: [], warnings }; - } - const requiresAssumptions = simulationReadiness === 'requires_assumptions' - || scenarioInputs.some((item) => item.readiness === 'requires_assumption'); - return { - status: requiresAssumptions ? 'REQUIRES_ASSUMPTIONS' : 'READY', - simulationReadiness, - program, - errors: [], - warnings - }; + const program: MechanicProgram = { + id: ruleId, + name: options.name?.trim() || `${sourceType}:${sourceId}`, + ...(scenarioInputs.length ? { scenarioInputs } : {}), + ...(parsedPools.length + ? { pools: parsedPools.map((pool) => pool.definition) } + : {}), + clauses, + }; + if (errors.length) { + return { + status: 'INVALID', + simulationReadiness, + program: null, + errors, + warnings, + }; + } + try { + const validation = validateMechanicProgram(program); + if (!validation.ok) { + return { + status: 'INVALID', + simulationReadiness, + program: null, + errors: validation.errors, + warnings, + }; + } + } catch { + return { + status: 'INVALID', + simulationReadiness, + program: null, + errors: [ + 'The persisted mechanic contains a structure that the runtime cannot validate.', + ], + warnings, + }; + } + if ( + !enabled(input.rule.configured) || + simulationReadiness === 'captured_only' + ) { + warnings.push( + 'The mechanic is retained for configuration and is not ready for simulation.', + ); + return { + status: 'CAPTURED_ONLY', + simulationReadiness, + program, + errors: [], + warnings, + }; + } + const requiresAssumptions = + simulationReadiness === 'requires_assumptions' || + scenarioInputs.some((item) => item.readiness === 'requires_assumption'); + return { + status: requiresAssumptions ? 'REQUIRES_ASSUMPTIONS' : 'READY', + simulationReadiness, + program, + errors: [], + warnings, + }; } diff --git a/src/lib/calculations/mechanic-recipes.test.ts b/src/lib/calculations/mechanic-recipes.test.ts index 24d7a01..23795ae 100644 --- a/src/lib/calculations/mechanic-recipes.test.ts +++ b/src/lib/calculations/mechanic-recipes.test.ts @@ -1,186 +1,258 @@ import { describe, expect, it } from 'vitest'; import { - TRIGGER_ACTION_KEYS, - validateTriggerConfiguration, - type TriggerActionKey, - type TriggerConfiguration + TRIGGER_ACTION_KEYS, + validateTriggerConfiguration, + type TriggerActionKey, + type TriggerConfiguration, } from './conditional.js'; import { - createMechanicRecipeForTrigger, - MECHANIC_RECIPE_REGISTRY, - type MechanicRecipeSeed + createMechanicRecipeForTrigger, + MECHANIC_RECIPE_REGISTRY, + type MechanicRecipeSeed, } from './mechanic-recipes.js'; import { validateMechanicProgram } from './mechanic.js'; const paramsByAction: Record = { - same_target_hits_within_window: { hitsRequired: 3, windowSeconds: 2 }, - distinct_enemies_within_window: { enemiesRequired: 3, windowSeconds: 4 }, - consecutive_same_target_hits: { hitsRequired: 4 }, - continuous_firing_duration: { seconds: 2.5 }, - while_firing: {}, - health_threshold_bands: { - bands: [ - { thresholdPercent: 25, outcomeKey: 'critical' }, - { thresholdPercent: 50, outcomeKey: 'damaged' } - ] - }, - headshot: {}, - body_shot: {}, - headshot_kill: {}, - weapon_kill: {}, - target_hit_within_distance: { meters: 15 }, - damage_taken: {}, - weapon_swap: {}, - reload: {}, - within_distance: { meters: 10 }, - extra_health_active: {} + same_target_hits_within_window: { hitsRequired: 3, windowSeconds: 2 }, + distinct_enemies_within_window: { enemiesRequired: 3, windowSeconds: 4 }, + consecutive_same_target_hits: { hitsRequired: 4 }, + continuous_firing_duration: { seconds: 2.5 }, + while_firing: {}, + health_threshold_bands: { + bands: [ + { thresholdPercent: 25, outcomeKey: 'critical' }, + { thresholdPercent: 50, outcomeKey: 'damaged' }, + ], + }, + headshot: {}, + body_shot: {}, + headshot_kill: {}, + weapon_kill: {}, + target_hit_within_distance: { meters: 15 }, + damage_taken: {}, + weapon_swap: {}, + reload: {}, + within_distance: { meters: 10 }, + extra_health_active: {}, }; function triggerFor(action: TriggerActionKey): TriggerConfiguration { - const trigger = validateTriggerConfiguration(action, paramsByAction[action]); - if (!trigger.ok) throw new Error(trigger.errors.join(' ')); - return trigger.value; + const trigger = validateTriggerConfiguration(action, paramsByAction[action]); + if (!trigger.ok) throw new Error(trigger.errors.join(' ')); + return trigger.value; } function seedFor(action: TriggerActionKey): MechanicRecipeSeed { - const trigger = triggerFor(action); - const result = createMechanicRecipeForTrigger(trigger); - if (result.status !== 'supported') throw new Error(result.reason); - return result.seed; + const trigger = triggerFor(action); + const result = createMechanicRecipeForTrigger(trigger); + if (result.status !== 'supported') throw new Error(result.reason); + return result.seed; } describe('mechanic recipe registry', () => { - it('covers every trigger action with a supported seed or an explicit reason', () => { - expect(Object.keys(MECHANIC_RECIPE_REGISTRY).sort()).toEqual([...TRIGGER_ACTION_KEYS].sort()); - for (const action of TRIGGER_ACTION_KEYS) { - const result = createMechanicRecipeForTrigger(triggerFor(action)); - expect(['supported', 'unsupported']).toContain(result.status); - if (result.status === 'unsupported') expect(result.reason.trim()).not.toBe(''); - else expect(result.seed.clauses.every((clause) => clause.recipeKey === action)).toBe(true); - } - }); + it('covers every trigger action with a supported seed or an explicit reason', () => { + expect(Object.keys(MECHANIC_RECIPE_REGISTRY).sort()).toEqual( + [...TRIGGER_ACTION_KEYS].sort(), + ); + for (const action of TRIGGER_ACTION_KEYS) { + const result = createMechanicRecipeForTrigger(triggerFor(action)); + expect(['supported', 'unsupported']).toContain(result.status); + if (result.status === 'unsupported') + expect(result.reason.trim()).not.toBe(''); + else + expect( + result.seed.clauses.every((clause) => clause.recipeKey === action), + ).toBe(true); + } + }); - it('produces a valid editable program for every supported recipe without hidden placeholders', () => { - for (const action of TRIGGER_ACTION_KEYS) { - const seed = seedFor(action); - const validation = validateMechanicProgram({ - id: `recipe-${action}`, - name: `Recipe ${action}`, - ...seed - }); - expect(validation, action).toMatchObject({ ok: true }); - } - }); + it('produces a valid editable program for every supported recipe without hidden placeholders', () => { + for (const action of TRIGGER_ACTION_KEYS) { + const seed = seedFor(action); + const validation = validateMechanicProgram({ + id: `recipe-${action}`, + name: `Recipe ${action}`, + ...seed, + }); + expect(validation, action).toMatchObject({ ok: true }); + } + }); - it('maps hit-family actions to same-target, distinct, and consecutive reducers', () => { - expect(seedFor('same_target_hits_within_window').clauses[0]).toMatchObject({ - when: 'weapon_hit', - if: { kind: 'exists', value: { source: 'event', path: 'targetId' } }, - reducer: { - kind: 'sliding_count', threshold: 3, windowSeconds: 2, reset: 'zero', - groupBy: { source: 'event', path: 'targetId' } - } - }); - expect(seedFor('distinct_enemies_within_window').clauses[0]).toMatchObject({ - when: 'weapon_hit', - reducer: { - kind: 'distinct_count', threshold: 3, windowSeconds: 4, - distinctBy: { source: 'event', path: 'targetId' } - } - }); - expect(seedFor('consecutive_same_target_hits').clauses[0]).toMatchObject({ - when: 'weapon_hit', - reducer: { - kind: 'consecutive_count', threshold: 4, - groupBy: { source: 'event', path: 'targetId' } - } - }); - }); + it('maps hit-family actions to same-target, distinct, and consecutive reducers', () => { + expect(seedFor('same_target_hits_within_window').clauses[0]).toMatchObject({ + when: 'weapon_hit', + if: { kind: 'exists', value: { source: 'event', path: 'targetId' } }, + reducer: { + kind: 'sliding_count', + threshold: 3, + windowSeconds: 2, + reset: 'zero', + groupBy: { source: 'event', path: 'targetId' }, + }, + }); + expect(seedFor('distinct_enemies_within_window').clauses[0]).toMatchObject({ + when: 'weapon_hit', + reducer: { + kind: 'distinct_count', + threshold: 3, + windowSeconds: 4, + distinctBy: { source: 'event', path: 'targetId' }, + }, + }); + expect(seedFor('consecutive_same_target_hits').clauses[0]).toMatchObject({ + when: 'weapon_hit', + reducer: { + kind: 'consecutive_count', + threshold: 4, + groupBy: { source: 'event', path: 'targetId' }, + }, + }); + }); - it('maps head and body shots to authoritative weapon-hit predicates', () => { - for (const [action, headshot] of [['headshot', true], ['body_shot', false]] as const) { - expect(seedFor(action).clauses[0]).toMatchObject({ - when: 'weapon_hit', - if: { - kind: 'all', - predicates: [ - { kind: 'exists', value: { source: 'event', path: 'targetId' } }, - { kind: 'exists', value: { source: 'event', path: 'values.headshot' } }, - { - kind: 'compare', - left: { source: 'event', path: 'values.headshot' }, - operator: 'eq', - right: headshot - } - ] - } - }); - } - }); + it('maps head and body shots to authoritative weapon-hit predicates', () => { + for (const [action, headshot] of [ + ['headshot', true], + ['body_shot', false], + ] as const) { + expect(seedFor(action).clauses[0]).toMatchObject({ + when: 'weapon_hit', + if: { + kind: 'all', + predicates: [ + { kind: 'exists', value: { source: 'event', path: 'targetId' } }, + { + kind: 'exists', + value: { source: 'event', path: 'values.headshot' }, + }, + { + kind: 'compare', + left: { source: 'event', path: 'values.headshot' }, + operator: 'eq', + right: headshot, + }, + ], + }, + }); + } + }); - it('models firing triggers as explicit lifecycle branches', () => { - const continuous = seedFor('continuous_firing_duration'); - expect(continuous.pools).toEqual([expect.objectContaining({ key: 'firing-active', max: 1 })]); - expect(continuous.clauses.map((clause) => clause.when)).toEqual([ - 'firing_started', 'firing_stopped', 'reload_started', 'tick' - ]); - expect(continuous.clauses.at(-1)).toMatchObject({ - reducer: { kind: 'duration', seconds: 2.5 }, - if: { kind: 'compare', left: { source: 'pool', pool: 'firing-active' }, operator: 'gt', right: 0 } - }); - expect(seedFor('while_firing').clauses.map((clause) => clause.when)).toEqual([ - 'firing_started', 'firing_stopped', 'reload_started' - ]); - }); + it('models firing triggers as explicit lifecycle branches', () => { + const continuous = seedFor('continuous_firing_duration'); + expect(continuous.pools).toEqual([ + expect.objectContaining({ key: 'firing-active', max: 1 }), + ]); + expect(continuous.clauses.map((clause) => clause.when)).toEqual([ + 'firing_started', + 'firing_stopped', + 'reload_started', + 'tick', + ]); + expect(continuous.clauses.at(-1)).toMatchObject({ + reducer: { kind: 'duration', seconds: 2.5 }, + if: { + kind: 'compare', + left: { source: 'pool', pool: 'firing-active' }, + operator: 'gt', + right: 0, + }, + }); + expect( + seedFor('while_firing').clauses.map((clause) => clause.when), + ).toEqual(['firing_started', 'firing_stopped', 'reload_started']); + }); - it('models health bands and persistent distance and Extra Health state as clearable branches', () => { - const health = seedFor('health_threshold_bands'); - expect(health.pools?.map((pool) => pool.key)).toEqual(['health-band-1', 'health-band-2']); - expect(health.clauses.map((clause) => clause.id)).toEqual([ - 'select-health-band-1', 'select-health-band-2', 'clear-health-bands' - ]); - expect(health.clauses[1].if).toMatchObject({ - kind: 'all', - predicates: expect.arrayContaining([ - { kind: 'compare', left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' }, operator: 'lt', right: 50 }, - { kind: 'compare', left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' }, operator: 'gte', right: 25 } - ]) - }); - expect(health.clauses.every((clause) => clause.when === 'tick')).toBe(true); - expect(health.clauses[2].if).toEqual({ - kind: 'compare', - left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' }, - operator: 'gte', - right: 50 - }); + it('models health bands and persistent distance and Extra Health state as clearable branches', () => { + const health = seedFor('health_threshold_bands'); + expect(health.pools?.map((pool) => pool.key)).toEqual([ + 'health-band-1', + 'health-band-2', + ]); + expect(health.clauses.map((clause) => clause.id)).toEqual([ + 'select-health-band-1', + 'select-health-band-2', + 'clear-health-bands', + ]); + expect(health.clauses[1].if).toMatchObject({ + kind: 'all', + predicates: expect.arrayContaining([ + { + kind: 'compare', + left: { + source: 'combat', + path: 'currentHealthPercent', + timing: 'current', + }, + operator: 'lt', + right: 50, + }, + { + kind: 'compare', + left: { + source: 'combat', + path: 'currentHealthPercent', + timing: 'current', + }, + operator: 'gte', + right: 25, + }, + ]), + }); + expect(health.clauses.every((clause) => clause.when === 'tick')).toBe(true); + expect(health.clauses[2].if).toEqual({ + kind: 'compare', + left: { + source: 'combat', + path: 'currentHealthPercent', + timing: 'current', + }, + operator: 'gte', + right: 50, + }); - expect(seedFor('target_hit_within_distance').clauses[0]).toMatchObject({ - when: 'weapon_hit', - if: { - kind: 'all', - predicates: expect.arrayContaining([ - { kind: 'exists', value: { source: 'event', path: 'targetId' } }, - { kind: 'compare', left: { source: 'event', path: 'values.distanceMeters' }, operator: 'lte', right: 15 } - ]) - } - }); + expect(seedFor('target_hit_within_distance').clauses[0]).toMatchObject({ + when: 'weapon_hit', + if: { + kind: 'all', + predicates: expect.arrayContaining([ + { kind: 'exists', value: { source: 'event', path: 'targetId' } }, + { + kind: 'compare', + left: { source: 'event', path: 'values.distanceMeters' }, + operator: 'lte', + right: 15, + }, + ]), + }, + }); - const withinDistance = seedFor('within_distance'); - expect(withinDistance.clauses.map((clause) => clause.id)).toEqual([ - 'enter-target-within-distance', 'exit-target-within-distance' - ]); - expect(withinDistance.clauses[0].if).toEqual({ - kind: 'compare', left: { source: 'event', path: 'values.distanceMeters' }, operator: 'lte', right: 10 - }); - expect(withinDistance.clauses[1].if).toMatchObject({ kind: 'not' }); + const withinDistance = seedFor('within_distance'); + expect(withinDistance.clauses.map((clause) => clause.id)).toEqual([ + 'enter-target-within-distance', + 'exit-target-within-distance', + ]); + expect(withinDistance.clauses[0].if).toEqual({ + kind: 'compare', + left: { source: 'event', path: 'values.distanceMeters' }, + operator: 'lte', + right: 10, + }); + expect(withinDistance.clauses[1].if).toMatchObject({ kind: 'not' }); - const extraHealth = seedFor('extra_health_active'); - expect(extraHealth.clauses.map((clause) => clause.id)).toEqual([ - 'enter-extra-health-active', 'exit-extra-health-active' - ]); - expect(extraHealth.clauses.every((clause) => clause.when === 'extra_health_changed')).toBe(true); - expect(extraHealth.clauses[0].if).toEqual({ - kind: 'compare', left: { source: 'event', path: 'values.hasExtraHealth' }, operator: 'eq', right: true - }); - }); + const extraHealth = seedFor('extra_health_active'); + expect(extraHealth.clauses.map((clause) => clause.id)).toEqual([ + 'enter-extra-health-active', + 'exit-extra-health-active', + ]); + expect( + extraHealth.clauses.every( + (clause) => clause.when === 'extra_health_changed', + ), + ).toBe(true); + expect(extraHealth.clauses[0].if).toEqual({ + kind: 'compare', + left: { source: 'event', path: 'values.hasExtraHealth' }, + operator: 'eq', + right: true, + }); + }); }); diff --git a/src/lib/calculations/mechanic-recipes.ts b/src/lib/calculations/mechanic-recipes.ts index d1735c4..81fa208 100644 --- a/src/lib/calculations/mechanic-recipes.ts +++ b/src/lib/calculations/mechanic-recipes.ts @@ -1,336 +1,471 @@ import type { - ConsecutiveHitsParams, - ContinuousFiringParams, - DistanceParams, - DistinctEnemiesParams, - HealthThresholdParams, - SameTargetHitsParams, - TriggerActionKey, - TriggerConfiguration, - TriggerParamsByAction + ConsecutiveHitsParams, + ContinuousFiringParams, + DistanceParams, + DistinctEnemiesParams, + HealthThresholdParams, + SameTargetHitsParams, + TriggerActionKey, + TriggerConfiguration, + TriggerParamsByAction, } from './conditional.js'; import type { - MechanicClause, - MechanicEventKind, - MechanicProgram, - PoolDefinition, - Predicate, - ValueReference + MechanicClause, + MechanicEventKind, + MechanicProgram, + PoolDefinition, + Predicate, + ValueReference, } from './mechanic.js'; /** A partial program that an editor can merge into a new mechanic draft. */ -export type MechanicRecipeSeed = Pick; +export type MechanicRecipeSeed = Pick< + MechanicProgram, + 'scenarioInputs' | 'pools' | 'clauses' +>; export type SupportedMechanicRecipe = { - status: 'supported'; - seed: MechanicRecipeSeed; + status: 'supported'; + seed: MechanicRecipeSeed; }; export type UnsupportedMechanicRecipe = { - status: 'unsupported'; - reason: string; + status: 'unsupported'; + reason: string; }; -export type MechanicRecipeResult = SupportedMechanicRecipe | UnsupportedMechanicRecipe; +export type MechanicRecipeResult = + SupportedMechanicRecipe | UnsupportedMechanicRecipe; -export type MechanicRecipeDefinition = { - key: K; - createSeed: (params: TriggerParamsByAction[K]) => MechanicRecipeResult; +export type MechanicRecipeDefinition< + K extends TriggerActionKey = TriggerActionKey, +> = { + key: K; + createSeed: (params: TriggerParamsByAction[K]) => MechanicRecipeResult; }; export type MechanicRecipeRegistry = { - [K in TriggerActionKey]: MechanicRecipeDefinition; + [K in TriggerActionKey]: MechanicRecipeDefinition; }; -const eventValue = (field: string): ValueReference => ({ source: 'event', path: `values.${field}` }); +const eventValue = (field: string): ValueReference => ({ + source: 'event', + path: `values.${field}`, +}); const targetExists: Predicate = { - kind: 'exists', - value: { source: 'event', path: 'targetId' } + kind: 'exists', + value: { source: 'event', path: 'targetId' }, }; const noMutation = [{ kind: 'none' as const }]; -const supported = (seed: MechanicRecipeSeed): SupportedMechanicRecipe => ({ status: 'supported', seed }); +const supported = (seed: MechanicRecipeSeed): SupportedMechanicRecipe => ({ + status: 'supported', + seed, +}); function directClause( - id: string, - when: MechanicClause['when'], - ifPredicate?: Predicate + id: string, + when: MechanicClause['when'], + ifPredicate?: Predicate, ): MechanicClause { - return { id, when, if: ifPredicate, reducer: { kind: 'none' }, mutations: noMutation }; + return { + id, + when, + if: ifPredicate, + reducer: { kind: 'none' }, + mutations: noMutation, + }; } -function firingLifecycleSeed(activePool: string, includeDuration?: number): MechanicRecipeSeed { - const pools: PoolDefinition[] = [{ - key: activePool, - label: 'Firing active', - scope: 'agent', - initial: 0, - min: 0, - max: 1 - }]; - const clauses: MechanicClause[] = [ - { - id: 'start-firing', - label: 'Start firing lifecycle', - when: 'firing_started', - reducer: { kind: 'none' }, - mutations: [{ kind: 'set', pool: activePool, value: 1 }] - }, - { - id: 'stop-firing', - label: 'Stop firing lifecycle', - when: 'firing_stopped', - reducer: { kind: 'none' }, - mutations: [{ kind: 'clear', pool: activePool }] - }, - { - id: 'reload-stops-firing', - label: 'Reload stops firing lifecycle', - when: 'reload_started', - reducer: { kind: 'none' }, - mutations: [{ kind: 'clear', pool: activePool }] - } - ]; - if (includeDuration !== undefined) { - clauses.push({ - id: 'continuous-firing-duration', - label: 'Reach continuous firing duration', - when: 'tick', - if: { - kind: 'compare', - left: { source: 'pool', pool: activePool }, - operator: 'gt', - right: 0 - }, - reducer: { kind: 'duration', seconds: includeDuration }, - mutations: noMutation - }); - } - return { pools, clauses }; +function firingLifecycleSeed( + activePool: string, + includeDuration?: number, +): MechanicRecipeSeed { + const pools: PoolDefinition[] = [ + { + key: activePool, + label: 'Firing active', + scope: 'agent', + initial: 0, + min: 0, + max: 1, + }, + ]; + const clauses: MechanicClause[] = [ + { + id: 'start-firing', + label: 'Start firing lifecycle', + when: 'firing_started', + reducer: { kind: 'none' }, + mutations: [{ kind: 'set', pool: activePool, value: 1 }], + }, + { + id: 'stop-firing', + label: 'Stop firing lifecycle', + when: 'firing_stopped', + reducer: { kind: 'none' }, + mutations: [{ kind: 'clear', pool: activePool }], + }, + { + id: 'reload-stops-firing', + label: 'Reload stops firing lifecycle', + when: 'reload_started', + reducer: { kind: 'none' }, + mutations: [{ kind: 'clear', pool: activePool }], + }, + ]; + if (includeDuration !== undefined) { + clauses.push({ + id: 'continuous-firing-duration', + label: 'Reach continuous firing duration', + when: 'tick', + if: { + kind: 'compare', + left: { source: 'pool', pool: activePool }, + operator: 'gt', + right: 0, + }, + reducer: { kind: 'duration', seconds: includeDuration }, + mutations: noMutation, + }); + } + return { pools, clauses }; } function healthBandSeed(params: HealthThresholdParams): MechanicRecipeSeed { - const sortedBands = [...params.bands].sort((left, right) => left.thresholdPercent - right.thresholdPercent); - const currentHealthPercent: ValueReference = { - source: 'combat', - path: 'currentHealthPercent', - timing: 'current' - }; - const pools: PoolDefinition[] = sortedBands.map((band, index) => ({ - key: `health-band-${index + 1}`, - label: `Health band: ${band.outcomeKey}`, - scope: 'agent', - initial: 0, - min: 0, - max: 1 - })); - const clauses: MechanicClause[] = sortedBands.map((band, index) => { - const lowerBound = sortedBands[index - 1]?.thresholdPercent; - const predicates: Predicate[] = [{ - kind: 'compare', - left: currentHealthPercent, - operator: 'lt', - right: band.thresholdPercent - }]; - if (lowerBound !== undefined) { - predicates.push({ - kind: 'compare', - left: currentHealthPercent, - operator: 'gte', - right: lowerBound - }); - } - return { - id: `select-health-band-${index + 1}`, - label: `Select ${band.outcomeKey} below ${band.thresholdPercent}% Health`, - when: 'tick', - if: { kind: 'all', predicates }, - reducer: { kind: 'none' }, - mutations: pools.map((pool, poolIndex) => - poolIndex === index - ? { kind: 'set' as const, pool: pool.key, value: 1 } - : { kind: 'clear' as const, pool: pool.key } - ) - }; - }); - clauses.push({ - id: 'clear-health-bands', - label: 'Clear health bands above the highest threshold', - when: 'tick', - if: { - kind: 'compare', - left: currentHealthPercent, - operator: 'gte', - right: sortedBands.at(-1)?.thresholdPercent ?? 0 - }, - reducer: { kind: 'none' }, - mutations: pools.map((pool) => ({ kind: 'clear' as const, pool: pool.key })) - }); - return { pools, clauses }; + const sortedBands = [...params.bands].sort( + (left, right) => left.thresholdPercent - right.thresholdPercent, + ); + const currentHealthPercent: ValueReference = { + source: 'combat', + path: 'currentHealthPercent', + timing: 'current', + }; + const pools: PoolDefinition[] = sortedBands.map((band, index) => ({ + key: `health-band-${index + 1}`, + label: `Health band: ${band.outcomeKey}`, + scope: 'agent', + initial: 0, + min: 0, + max: 1, + })); + const clauses: MechanicClause[] = sortedBands.map((band, index) => { + const lowerBound = sortedBands[index - 1]?.thresholdPercent; + const predicates: Predicate[] = [ + { + kind: 'compare', + left: currentHealthPercent, + operator: 'lt', + right: band.thresholdPercent, + }, + ]; + if (lowerBound !== undefined) { + predicates.push({ + kind: 'compare', + left: currentHealthPercent, + operator: 'gte', + right: lowerBound, + }); + } + return { + id: `select-health-band-${index + 1}`, + label: `Select ${band.outcomeKey} below ${band.thresholdPercent}% Health`, + when: 'tick', + if: { kind: 'all', predicates }, + reducer: { kind: 'none' }, + mutations: pools.map((pool, poolIndex) => + poolIndex === index + ? { kind: 'set' as const, pool: pool.key, value: 1 } + : { kind: 'clear' as const, pool: pool.key }, + ), + }; + }); + clauses.push({ + id: 'clear-health-bands', + label: 'Clear health bands above the highest threshold', + when: 'tick', + if: { + kind: 'compare', + left: currentHealthPercent, + operator: 'gte', + right: sortedBands.at(-1)?.thresholdPercent ?? 0, + }, + reducer: { kind: 'none' }, + mutations: pools.map((pool) => ({ + kind: 'clear' as const, + pool: pool.key, + })), + }); + return { pools, clauses }; } function eventLifecycleSeed( - pool: PoolDefinition, - event: MechanicEventKind, - activePredicate: Predicate, - label: string + pool: PoolDefinition, + event: MechanicEventKind, + activePredicate: Predicate, + label: string, ): MechanicRecipeSeed { - return { - pools: [pool], - clauses: [ - { - id: `enter-${pool.key}`, - label: `Enter ${label}`, - when: event, - if: activePredicate, - reducer: { kind: 'none' }, - mutations: [{ kind: 'set', pool: pool.key, value: 1 }] - }, - { - id: `exit-${pool.key}`, - label: `Exit ${label}`, - when: event, - if: { kind: 'not', predicate: activePredicate }, - reducer: { kind: 'none' }, - mutations: [{ kind: 'clear', pool: pool.key }] - } - ] - }; + return { + pools: [pool], + clauses: [ + { + id: `enter-${pool.key}`, + label: `Enter ${label}`, + when: event, + if: activePredicate, + reducer: { kind: 'none' }, + mutations: [{ kind: 'set', pool: pool.key, value: 1 }], + }, + { + id: `exit-${pool.key}`, + label: `Exit ${label}`, + when: event, + if: { kind: 'not', predicate: activePredicate }, + reducer: { kind: 'none' }, + mutations: [{ kind: 'clear', pool: pool.key }], + }, + ], + }; } export const MECHANIC_RECIPE_REGISTRY: Readonly = { - same_target_hits_within_window: { - key: 'same_target_hits_within_window', - createSeed: (params) => { - const value = params as SameTargetHitsParams; - return supported({ clauses: [{ - id: 'same-target-hit-window', - when: 'weapon_hit', - if: targetExists, - reducer: { - kind: 'sliding_count', threshold: value.hitsRequired, windowSeconds: value.windowSeconds, - reset: 'zero', groupBy: { source: 'event', path: 'targetId' } - }, - mutations: noMutation - }] }); - } - }, - distinct_enemies_within_window: { - key: 'distinct_enemies_within_window', - createSeed: (params) => { - const value = params as DistinctEnemiesParams; - return supported({ clauses: [{ - id: 'distinct-enemy-hit-window', when: 'weapon_hit', if: targetExists, - reducer: { - kind: 'distinct_count', threshold: value.enemiesRequired, windowSeconds: value.windowSeconds, - distinctBy: { source: 'event', path: 'targetId' } - }, - mutations: noMutation - }] }); - } - }, - consecutive_same_target_hits: { - key: 'consecutive_same_target_hits', - createSeed: (params) => { - const value = params as ConsecutiveHitsParams; - return supported({ clauses: [{ - id: 'consecutive-same-target-hits', when: 'weapon_hit', if: targetExists, - reducer: { - kind: 'consecutive_count', threshold: value.hitsRequired, - groupBy: { source: 'event', path: 'targetId' } - }, - mutations: noMutation - }] }); - } - }, - continuous_firing_duration: { - key: 'continuous_firing_duration', - createSeed: (params) => supported(firingLifecycleSeed('firing-active', (params as ContinuousFiringParams).seconds)) - }, - while_firing: { - key: 'while_firing', - createSeed: (_params) => supported(firingLifecycleSeed('firing-active')) - }, - health_threshold_bands: { - key: 'health_threshold_bands', - createSeed: (params) => supported(healthBandSeed(params as HealthThresholdParams)) - }, - headshot: { - key: 'headshot', - createSeed: (_params) => supported({ clauses: [directClause('headshot', 'weapon_hit', { - kind: 'all', - predicates: [ - targetExists, - { kind: 'exists', value: eventValue('headshot') }, - { kind: 'compare', left: eventValue('headshot'), operator: 'eq', right: true } - ] - })] }) - }, - body_shot: { - key: 'body_shot', - createSeed: (_params) => supported({ clauses: [directClause('body-shot', 'weapon_hit', { - kind: 'all', - predicates: [ - targetExists, - { kind: 'exists', value: eventValue('headshot') }, - { kind: 'compare', left: eventValue('headshot'), operator: 'eq', right: false } - ] - })] }) - }, - headshot_kill: { - key: 'headshot_kill', - createSeed: (_params) => supported({ clauses: [directClause('headshot-kill', 'enemy_killed', { - kind: 'compare', left: eventValue('headshot'), operator: 'eq', right: true - })] }) - }, - weapon_kill: { - key: 'weapon_kill', - createSeed: (_params) => supported({ clauses: [directClause('weapon-kill', 'enemy_killed')] }) - }, - target_hit_within_distance: { - key: 'target_hit_within_distance', - createSeed: (params) => { - const value = params as DistanceParams; - return supported({ clauses: [directClause('target-hit-within-distance', 'weapon_hit', { - kind: 'all', predicates: [targetExists, { - kind: 'compare', left: eventValue('distanceMeters'), operator: 'lte', right: value.meters - }] - })] }); - } - }, - damage_taken: { - key: 'damage_taken', - createSeed: (_params) => supported({ clauses: [directClause('damage-taken', 'health_damage_taken')] }) - }, - weapon_swap: { - key: 'weapon_swap', - createSeed: (_params) => supported({ clauses: [directClause('weapon-swap', 'weapon_swapped')] }) - }, - reload: { - key: 'reload', - createSeed: (_params) => supported({ clauses: [directClause('reload-started', 'reload_started')] }) - }, - within_distance: { - key: 'within_distance', - createSeed: (params) => { - const value = params as DistanceParams; - return supported(eventLifecycleSeed( - { key: 'target-within-distance', label: 'Target within distance', scope: 'agent', initial: 0, min: 0, max: 1 }, - 'proximity_snapshot', - { kind: 'compare', left: eventValue('distanceMeters'), operator: 'lte', right: value.meters }, - `target within ${value.meters}m` - )); - } - }, - extra_health_active: { - key: 'extra_health_active', - createSeed: (_params) => supported(eventLifecycleSeed( - { key: 'extra-health-active', label: 'Extra Health active', scope: 'agent', initial: 0, min: 0, max: 1 }, - 'extra_health_changed', - { kind: 'compare', left: eventValue('hasExtraHealth'), operator: 'eq', right: true }, - 'Extra Health active' - )) - } + same_target_hits_within_window: { + key: 'same_target_hits_within_window', + createSeed: (params) => { + const value = params as SameTargetHitsParams; + return supported({ + clauses: [ + { + id: 'same-target-hit-window', + when: 'weapon_hit', + if: targetExists, + reducer: { + kind: 'sliding_count', + threshold: value.hitsRequired, + windowSeconds: value.windowSeconds, + reset: 'zero', + groupBy: { source: 'event', path: 'targetId' }, + }, + mutations: noMutation, + }, + ], + }); + }, + }, + distinct_enemies_within_window: { + key: 'distinct_enemies_within_window', + createSeed: (params) => { + const value = params as DistinctEnemiesParams; + return supported({ + clauses: [ + { + id: 'distinct-enemy-hit-window', + when: 'weapon_hit', + if: targetExists, + reducer: { + kind: 'distinct_count', + threshold: value.enemiesRequired, + windowSeconds: value.windowSeconds, + distinctBy: { source: 'event', path: 'targetId' }, + }, + mutations: noMutation, + }, + ], + }); + }, + }, + consecutive_same_target_hits: { + key: 'consecutive_same_target_hits', + createSeed: (params) => { + const value = params as ConsecutiveHitsParams; + return supported({ + clauses: [ + { + id: 'consecutive-same-target-hits', + when: 'weapon_hit', + if: targetExists, + reducer: { + kind: 'consecutive_count', + threshold: value.hitsRequired, + groupBy: { source: 'event', path: 'targetId' }, + }, + mutations: noMutation, + }, + ], + }); + }, + }, + continuous_firing_duration: { + key: 'continuous_firing_duration', + createSeed: (params) => + supported( + firingLifecycleSeed( + 'firing-active', + (params as ContinuousFiringParams).seconds, + ), + ), + }, + while_firing: { + key: 'while_firing', + createSeed: (_params) => supported(firingLifecycleSeed('firing-active')), + }, + health_threshold_bands: { + key: 'health_threshold_bands', + createSeed: (params) => + supported(healthBandSeed(params as HealthThresholdParams)), + }, + headshot: { + key: 'headshot', + createSeed: (_params) => + supported({ + clauses: [ + directClause('headshot', 'weapon_hit', { + kind: 'all', + predicates: [ + targetExists, + { kind: 'exists', value: eventValue('headshot') }, + { + kind: 'compare', + left: eventValue('headshot'), + operator: 'eq', + right: true, + }, + ], + }), + ], + }), + }, + body_shot: { + key: 'body_shot', + createSeed: (_params) => + supported({ + clauses: [ + directClause('body-shot', 'weapon_hit', { + kind: 'all', + predicates: [ + targetExists, + { kind: 'exists', value: eventValue('headshot') }, + { + kind: 'compare', + left: eventValue('headshot'), + operator: 'eq', + right: false, + }, + ], + }), + ], + }), + }, + headshot_kill: { + key: 'headshot_kill', + createSeed: (_params) => + supported({ + clauses: [ + directClause('headshot-kill', 'enemy_killed', { + kind: 'compare', + left: eventValue('headshot'), + operator: 'eq', + right: true, + }), + ], + }), + }, + weapon_kill: { + key: 'weapon_kill', + createSeed: (_params) => + supported({ clauses: [directClause('weapon-kill', 'enemy_killed')] }), + }, + target_hit_within_distance: { + key: 'target_hit_within_distance', + createSeed: (params) => { + const value = params as DistanceParams; + return supported({ + clauses: [ + directClause('target-hit-within-distance', 'weapon_hit', { + kind: 'all', + predicates: [ + targetExists, + { + kind: 'compare', + left: eventValue('distanceMeters'), + operator: 'lte', + right: value.meters, + }, + ], + }), + ], + }); + }, + }, + damage_taken: { + key: 'damage_taken', + createSeed: (_params) => + supported({ + clauses: [directClause('damage-taken', 'health_damage_taken')], + }), + }, + weapon_swap: { + key: 'weapon_swap', + createSeed: (_params) => + supported({ clauses: [directClause('weapon-swap', 'weapon_swapped')] }), + }, + reload: { + key: 'reload', + createSeed: (_params) => + supported({ + clauses: [directClause('reload-started', 'reload_started')], + }), + }, + within_distance: { + key: 'within_distance', + createSeed: (params) => { + const value = params as DistanceParams; + return supported( + eventLifecycleSeed( + { + key: 'target-within-distance', + label: 'Target within distance', + scope: 'agent', + initial: 0, + min: 0, + max: 1, + }, + 'proximity_snapshot', + { + kind: 'compare', + left: eventValue('distanceMeters'), + operator: 'lte', + right: value.meters, + }, + `target within ${value.meters}m`, + ), + ); + }, + }, + extra_health_active: { + key: 'extra_health_active', + createSeed: (_params) => + supported( + eventLifecycleSeed( + { + key: 'extra-health-active', + label: 'Extra Health active', + scope: 'agent', + initial: 0, + min: 0, + max: 1, + }, + 'extra_health_changed', + { + kind: 'compare', + left: eventValue('hasExtraHealth'), + operator: 'eq', + right: true, + }, + 'Extra Health active', + ), + ), + }, }; /** @@ -338,24 +473,28 @@ export const MECHANIC_RECIPE_REGISTRY: Readonly = { * validated by the trigger family registry. */ export function createMechanicRecipe( - action: K, - params: TriggerParamsByAction[K] + action: K, + params: TriggerParamsByAction[K], ): MechanicRecipeResult { - const definition = MECHANIC_RECIPE_REGISTRY[action] as MechanicRecipeDefinition; - const result = definition.createSeed(params); - if (result.status === 'unsupported') return result; - return { - status: 'supported', - seed: { - ...result.seed, - clauses: result.seed.clauses.map((clause) => ({ - ...clause, - recipeKey: action - })) - } - }; + const definition = MECHANIC_RECIPE_REGISTRY[ + action + ] as MechanicRecipeDefinition; + const result = definition.createSeed(params); + if (result.status === 'unsupported') return result; + return { + status: 'supported', + seed: { + ...result.seed, + clauses: result.seed.clauses.map((clause) => ({ + ...clause, + recipeKey: action, + })), + }, + }; } -export function createMechanicRecipeForTrigger(trigger: TriggerConfiguration): MechanicRecipeResult { - return createMechanicRecipe(trigger.action, trigger.params); +export function createMechanicRecipeForTrigger( + trigger: TriggerConfiguration, +): MechanicRecipeResult { + return createMechanicRecipe(trigger.action, trigger.params); } diff --git a/src/lib/calculations/mechanic.test.ts b/src/lib/calculations/mechanic.test.ts index 98bb229..788fbd7 100644 --- a/src/lib/calculations/mechanic.test.ts +++ b/src/lib/calculations/mechanic.test.ts @@ -1,1985 +1,2553 @@ import { describe, expect, it } from 'vitest'; import { - MECHANIC_EVENT_REGISTRY, - evaluateMechanicProgram, - formatMechanicProgram, - validateMechanicProgram, - type MechanicEvent, - type MechanicEventKind, - type MechanicProgram, - type NumericExpression + MECHANIC_EVENT_REGISTRY, + evaluateMechanicProgram, + formatMechanicProgram, + validateMechanicProgram, + type MechanicEvent, + type MechanicEventKind, + type MechanicProgram, + type NumericExpression, } from './mechanic.js'; -const custom = (encounterSeconds = 30, inputs: Record = {}) => ({ - mode: 'custom' as const, - encounterSeconds, - inputs +const custom = ( + encounterSeconds = 30, + inputs: Record = {}, +) => ({ + mode: 'custom' as const, + encounterSeconds, + inputs, }); describe('structured mechanic runtime', () => { - it('advertises the authoritative weapon-hit target and headshot fields', () => { - expect(MECHANIC_EVENT_REGISTRY.weapon_hit).toMatchObject({ - description: expect.stringContaining('previous target'), - fields: [ - { path: 'targetId', label: 'Target ID', type: 'string' }, - { path: 'values.previousTargetId', label: 'Previous target ID', type: 'string' }, - { path: 'values.headshot', label: 'Headshot', type: 'boolean' }, - { path: 'values.critical', label: 'Critical hit', type: 'boolean' }, - { path: 'values.behindCover', label: 'Agent behind cover', type: 'boolean' }, - { path: 'values.distanceMeters', label: 'Target distance', type: 'number' } - ] - }); - }); + it('advertises the authoritative weapon-hit target and headshot fields', () => { + expect(MECHANIC_EVENT_REGISTRY.weapon_hit).toMatchObject({ + description: expect.stringContaining('previous target'), + fields: [ + { path: 'targetId', label: 'Target ID', type: 'string' }, + { + path: 'values.previousTargetId', + label: 'Previous target ID', + type: 'string', + }, + { path: 'values.headshot', label: 'Headshot', type: 'boolean' }, + { path: 'values.critical', label: 'Critical hit', type: 'boolean' }, + { + path: 'values.behindCover', + label: 'Agent behind cover', + type: 'boolean', + }, + { + path: 'values.distanceMeters', + label: 'Target distance', + type: 'number', + }, + ], + }); + }); - it('advertises imported support and equipment-use trigger events', () => { - expect(MECHANIC_EVENT_REGISTRY.ally_downed).toMatchObject({ - key: 'ally_downed', - label: 'Ally downed' - }); - expect(MECHANIC_EVENT_REGISTRY.agent_downed).toMatchObject({ - key: 'agent_downed', - label: 'Agent downed' - }); - expect(MECHANIC_EVENT_REGISTRY.ally_rescued).toMatchObject({ - key: 'ally_rescued', - label: 'Ally rescued' - }); - expect(MECHANIC_EVENT_REGISTRY.grenade_thrown).toMatchObject({ - key: 'grenade_thrown', - label: 'Grenade thrown' - }); - expect(MECHANIC_EVENT_REGISTRY.medical_kit_used).toMatchObject({ - key: 'medical_kit_used', - label: 'Medical Kit used' - }); - expect(MECHANIC_EVENT_REGISTRY.signature_ability_deployed).toMatchObject({ - key: 'signature_ability_deployed', - label: 'Signature Ability deployed' - }); - expect(MECHANIC_EVENT_REGISTRY.skill_multishot_triggered).toMatchObject({ - key: 'skill_multishot_triggered', - label: 'Skill Multi-Shot triggered' - }); - }); + it('advertises imported support and equipment-use trigger events', () => { + expect(MECHANIC_EVENT_REGISTRY.ally_downed).toMatchObject({ + key: 'ally_downed', + label: 'Ally downed', + }); + expect(MECHANIC_EVENT_REGISTRY.agent_downed).toMatchObject({ + key: 'agent_downed', + label: 'Agent downed', + }); + expect(MECHANIC_EVENT_REGISTRY.ally_rescued).toMatchObject({ + key: 'ally_rescued', + label: 'Ally rescued', + }); + expect(MECHANIC_EVENT_REGISTRY.grenade_thrown).toMatchObject({ + key: 'grenade_thrown', + label: 'Grenade thrown', + }); + expect(MECHANIC_EVENT_REGISTRY.medical_kit_used).toMatchObject({ + key: 'medical_kit_used', + label: 'Medical Kit used', + }); + expect(MECHANIC_EVENT_REGISTRY.signature_ability_deployed).toMatchObject({ + key: 'signature_ability_deployed', + label: 'Signature Ability deployed', + }); + expect(MECHANIC_EVENT_REGISTRY.skill_multishot_triggered).toMatchObject({ + key: 'skill_multishot_triggered', + label: 'Skill Multi-Shot triggered', + }); + }); - it('advertises the authoritative Extra Health state contract', () => { - expect(MECHANIC_EVENT_REGISTRY.extra_health_changed).toEqual({ - key: 'extra_health_changed', - label: 'Extra Health changed', - description: expect.stringContaining('transition to zero'), - strictFields: true, - fields: [ - { - path: 'values.hasExtraHealth', - label: 'Has Extra Health', - type: 'boolean', - required: true - }, - { - path: 'values.currentExtraHealth', - label: 'Current Extra Health', - type: 'number', - minimum: 0 - } - ] - }); - }); + it('advertises the authoritative Extra Health state contract', () => { + expect(MECHANIC_EVENT_REGISTRY.extra_health_changed).toEqual({ + key: 'extra_health_changed', + label: 'Extra Health changed', + description: expect.stringContaining('transition to zero'), + strictFields: true, + fields: [ + { + path: 'values.hasExtraHealth', + label: 'Has Extra Health', + type: 'boolean', + required: true, + }, + { + path: 'values.currentExtraHealth', + label: 'Current Extra Health', + type: 'number', + minimum: 0, + }, + ], + }); + }); - it('gains one capped stack per second with Extra Health and clears all stacks on depletion', () => { - const skillIntensityId = 'skill-intensity'; - const program: MechanicProgram = { - id: 'electrical-protection', - name: 'Electrical Protection', - pools: [ - { key: 'extra-health-active', label: 'Extra Health active', scope: 'agent', initial: 0, min: 0, max: 1 }, - { key: 'stacks', label: 'Electrical Protection stacks', scope: 'agent', initial: 0, min: 0, max: 10, bestCaseValue: 10 } - ], - clauses: [ - { - id: 'extra-health-present', - when: 'extra_health_changed', - if: { kind: 'compare', left: { source: 'event', path: 'values.hasExtraHealth' }, operator: 'eq', right: true }, - reducer: { kind: 'none' }, - mutations: [{ kind: 'set', pool: 'extra-health-active', value: 1 }] - }, - { - id: 'extra-health-depleted', - when: 'extra_health_changed', - if: { kind: 'compare', left: { source: 'event', path: 'values.hasExtraHealth' }, operator: 'eq', right: false }, - reducer: { kind: 'none' }, - mutations: [ - { kind: 'clear', pool: 'extra-health-active' }, - { kind: 'clear', pool: 'stacks' } - ] - }, - { - id: 'gain-stack', - when: 'tick', - if: { kind: 'compare', left: { source: 'pool', pool: 'extra-health-active' }, operator: 'eq', right: 1 }, - reducer: { kind: 'periodic', intervalSeconds: 1 }, - mutations: [{ kind: 'add', pool: 'stacks', value: 1 }], - outcomes: [{ - id: 'skill-intensity', - label: 'Skill Intensity', - phase: 'while_active', - kind: 'attribute_modifier', - scope: 'all_skills', - basis: 'flat', - attributeId: skillIntensityId, - value: 1.8, - statePool: 'stacks', - perStack: true - }] - } - ] - }; - const ticks = Array.from({ length: 13 }, (_, index): MechanicEvent => ({ kind: 'tick', at: index })); - const capped = evaluateMechanicProgram(program, [ - { kind: 'extra_health_changed', at: 0, values: { hasExtraHealth: true, currentExtraHealth: 500 } }, - ...ticks - ], custom(12), { currentHealth: 1000, maxHealth: 1000, attributes: { [skillIntensityId]: 0 } }); - expect(capped.status).toBe('OK'); - expect(capped.finalState).toEqual(expect.arrayContaining([ - expect.objectContaining({ pool: 'stacks', value: 10 }) - ])); - expect(capped.outcomes.at(-1)).toMatchObject({ value: 18, stateValue: 10 }); + it('gains one capped stack per second with Extra Health and clears all stacks on depletion', () => { + const skillIntensityId = 'skill-intensity'; + const program: MechanicProgram = { + id: 'electrical-protection', + name: 'Electrical Protection', + pools: [ + { + key: 'extra-health-active', + label: 'Extra Health active', + scope: 'agent', + initial: 0, + min: 0, + max: 1, + }, + { + key: 'stacks', + label: 'Electrical Protection stacks', + scope: 'agent', + initial: 0, + min: 0, + max: 10, + bestCaseValue: 10, + }, + ], + clauses: [ + { + id: 'extra-health-present', + when: 'extra_health_changed', + if: { + kind: 'compare', + left: { source: 'event', path: 'values.hasExtraHealth' }, + operator: 'eq', + right: true, + }, + reducer: { kind: 'none' }, + mutations: [{ kind: 'set', pool: 'extra-health-active', value: 1 }], + }, + { + id: 'extra-health-depleted', + when: 'extra_health_changed', + if: { + kind: 'compare', + left: { source: 'event', path: 'values.hasExtraHealth' }, + operator: 'eq', + right: false, + }, + reducer: { kind: 'none' }, + mutations: [ + { kind: 'clear', pool: 'extra-health-active' }, + { kind: 'clear', pool: 'stacks' }, + ], + }, + { + id: 'gain-stack', + when: 'tick', + if: { + kind: 'compare', + left: { source: 'pool', pool: 'extra-health-active' }, + operator: 'eq', + right: 1, + }, + reducer: { kind: 'periodic', intervalSeconds: 1 }, + mutations: [{ kind: 'add', pool: 'stacks', value: 1 }], + outcomes: [ + { + id: 'skill-intensity', + label: 'Skill Intensity', + phase: 'while_active', + kind: 'attribute_modifier', + scope: 'all_skills', + basis: 'flat', + attributeId: skillIntensityId, + value: 1.8, + statePool: 'stacks', + perStack: true, + }, + ], + }, + ], + }; + const ticks = Array.from({ length: 13 }, (_, index): MechanicEvent => ({ + kind: 'tick', + at: index, + })); + const capped = evaluateMechanicProgram( + program, + [ + { + kind: 'extra_health_changed', + at: 0, + values: { hasExtraHealth: true, currentExtraHealth: 500 }, + }, + ...ticks, + ], + custom(12), + { + currentHealth: 1000, + maxHealth: 1000, + attributes: { [skillIntensityId]: 0 }, + }, + ); + expect(capped.status).toBe('OK'); + expect(capped.finalState).toEqual( + expect.arrayContaining([ + expect.objectContaining({ pool: 'stacks', value: 10 }), + ]), + ); + expect(capped.outcomes.at(-1)).toMatchObject({ value: 18, stateValue: 10 }); - const depleted = evaluateMechanicProgram(program, [ - { kind: 'extra_health_changed', at: 0, values: { hasExtraHealth: true, currentExtraHealth: 500 } }, - { kind: 'tick', at: 0 }, - { kind: 'tick', at: 1 }, - { kind: 'extra_health_changed', at: 1.5, values: { hasExtraHealth: true, currentExtraHealth: 100 } }, - { kind: 'tick', at: 2 }, - { kind: 'extra_health_changed', at: 2.5, values: { hasExtraHealth: false, currentExtraHealth: 0 } }, - { kind: 'tick', at: 3 } - ], custom(3), { currentHealth: 1000, maxHealth: 1000, attributes: { [skillIntensityId]: 0 } }); - expect(depleted.status).toBe('OK'); - expect(depleted.finalState).toEqual(expect.arrayContaining([ - expect.objectContaining({ pool: 'extra-health-active', value: 0 }), - expect.objectContaining({ pool: 'stacks', value: 0 }) - ])); - expect(depleted.emissions.filter((emission) => emission.clauseId === 'gain-stack')).toHaveLength(2); - }); + const depleted = evaluateMechanicProgram( + program, + [ + { + kind: 'extra_health_changed', + at: 0, + values: { hasExtraHealth: true, currentExtraHealth: 500 }, + }, + { kind: 'tick', at: 0 }, + { kind: 'tick', at: 1 }, + { + kind: 'extra_health_changed', + at: 1.5, + values: { hasExtraHealth: true, currentExtraHealth: 100 }, + }, + { kind: 'tick', at: 2 }, + { + kind: 'extra_health_changed', + at: 2.5, + values: { hasExtraHealth: false, currentExtraHealth: 0 }, + }, + { kind: 'tick', at: 3 }, + ], + custom(3), + { + currentHealth: 1000, + maxHealth: 1000, + attributes: { [skillIntensityId]: 0 }, + }, + ); + expect(depleted.status).toBe('OK'); + expect(depleted.finalState).toEqual( + expect.arrayContaining([ + expect.objectContaining({ pool: 'extra-health-active', value: 0 }), + expect.objectContaining({ pool: 'stacks', value: 0 }), + ]), + ); + expect( + depleted.emissions.filter( + (emission) => emission.clauseId === 'gain-stack', + ), + ).toHaveLength(2); + }); - it('rejects malformed Extra Health state fields and inconsistent amounts', () => { - const program: MechanicProgram = { - id: 'extra-health-state', - name: 'Extra Health state', - clauses: [{ - id: 'state-change', - when: 'extra_health_changed', - if: { kind: 'compare', left: { source: 'event', path: 'values.hasExtraHealth' }, operator: 'eq', right: true }, - reducer: { kind: 'none' }, - mutations: [{ kind: 'none' }] - }] - }; - for (const event of [ - { kind: 'extra_health_changed', at: 0, values: {} }, - { kind: 'extra_health_changed', at: 0, values: { hasExtraHealth: true, currentExtraHealth: -1 } }, - { kind: 'extra_health_changed', at: 0, values: { hasExtraHealth: false, currentExtraHealth: 1 } } - ] as MechanicEvent[]) { - expect(evaluateMechanicProgram(program, [event], custom(1)).status).toBe('INVALID'); - } + it('rejects malformed Extra Health state fields and inconsistent amounts', () => { + const program: MechanicProgram = { + id: 'extra-health-state', + name: 'Extra Health state', + clauses: [ + { + id: 'state-change', + when: 'extra_health_changed', + if: { + kind: 'compare', + left: { source: 'event', path: 'values.hasExtraHealth' }, + operator: 'eq', + right: true, + }, + reducer: { kind: 'none' }, + mutations: [{ kind: 'none' }], + }, + ], + }; + for (const event of [ + { kind: 'extra_health_changed', at: 0, values: {} }, + { + kind: 'extra_health_changed', + at: 0, + values: { hasExtraHealth: true, currentExtraHealth: -1 }, + }, + { + kind: 'extra_health_changed', + at: 0, + values: { hasExtraHealth: false, currentExtraHealth: 1 }, + }, + ] as MechanicEvent[]) { + expect(evaluateMechanicProgram(program, [event], custom(1)).status).toBe( + 'INVALID', + ); + } - const unknownField = structuredClone(program); - unknownField.clauses[0].if = { - kind: 'compare', - left: { source: 'event', path: 'values.extraHealthActive' }, - operator: 'eq', - right: true - }; - expect(validateMechanicProgram(unknownField)).toMatchObject({ - ok: false, - errors: [expect.stringContaining('is not defined for Extra Health changed')] - }); - }); + const unknownField = structuredClone(program); + unknownField.clauses[0].if = { + kind: 'compare', + left: { source: 'event', path: 'values.extraHealthActive' }, + operator: 'eq', + right: true, + }; + expect(validateMechanicProgram(unknownField)).toMatchObject({ + ok: false, + errors: [ + expect.stringContaining('is not defined for Extra Health changed'), + ], + }); + }); - it('executes imported talent trigger families as first-class events', () => { - const eventKinds: MechanicEventKind[] = [ - 'ally_downed', - 'agent_downed', - 'ally_rescued', - 'grenade_thrown', - 'medical_kit_used', - 'signature_ability_deployed', - 'skill_multishot_triggered' - ]; - const program: MechanicProgram = { - id: 'imported-talent-events', - name: 'Imported talent events', - clauses: eventKinds.map((when) => ({ - id: when, - when, - reducer: { kind: 'none' }, - mutations: [{ kind: 'none' }], - outcomes: [{ - id: `${when}-marker`, - label: `${when} marker`, - phase: 'after_event', - kind: 'marker', - scope: 'self', - basis: 'flat', - value: 1 - }] - })) - }; + it('executes imported talent trigger families as first-class events', () => { + const eventKinds: MechanicEventKind[] = [ + 'ally_downed', + 'agent_downed', + 'ally_rescued', + 'grenade_thrown', + 'medical_kit_used', + 'signature_ability_deployed', + 'skill_multishot_triggered', + ]; + const program: MechanicProgram = { + id: 'imported-talent-events', + name: 'Imported talent events', + clauses: eventKinds.map((when) => ({ + id: when, + when, + reducer: { kind: 'none' }, + mutations: [{ kind: 'none' }], + outcomes: [ + { + id: `${when}-marker`, + label: `${when} marker`, + phase: 'after_event', + kind: 'marker', + scope: 'self', + basis: 'flat', + value: 1, + }, + ], + })), + }; - expect(validateMechanicProgram(program)).toMatchObject({ ok: true }); - const events: MechanicEvent[] = eventKinds.map((kind, index) => ({ kind, at: index + 1 })); - expect(evaluateMechanicProgram(program, events, custom()).outcomes.map( - (outcome) => outcome.outcomeId - )).toEqual(eventKinds.map((kind) => `${kind}-marker`)); - }); + expect(validateMechanicProgram(program)).toMatchObject({ ok: true }); + const events: MechanicEvent[] = eventKinds.map((kind, index) => ({ + kind, + at: index + 1, + })); + expect( + evaluateMechanicProgram(program, events, custom()).outcomes.map( + (outcome) => outcome.outcomeId, + ), + ).toEqual(eventKinds.map((kind) => `${kind}-marker`)); + }); - it('derives the previous weapon-hit target for target-switch mechanics', () => { - const program: MechanicProgram = { - id: 'target-switch-stacks', - name: 'Target Switch Stacks', - pools: [{ - key: 'stacks', - label: 'Stacks', - scope: 'agent', - initial: 0, - min: 0, - max: 100, - expiresAfterSeconds: 5 - }], - clauses: [ - { - id: 'halve-on-switch', - when: 'weapon_hit', - if: { - kind: 'all', - predicates: [ - { kind: 'exists', value: { source: 'event', path: 'values.previousTargetId' } }, - { - kind: 'compare', - left: { source: 'event', path: 'values.previousTargetId' }, - operator: 'ne', - right: { source: 'event', path: 'targetId' } - } - ] - }, - reducer: { kind: 'none' }, - mutations: [{ - kind: 'set', - pool: 'stacks', - value: { - op: 'multiply', - left: { ref: { source: 'pool', pool: 'stacks' } }, - right: 0.5 - } - }] - }, - { - id: 'build-stacks', - when: 'weapon_hit', - if: { kind: 'exists', value: { source: 'event', path: 'targetId' } }, - reducer: { kind: 'none' }, - mutations: [{ kind: 'add', pool: 'stacks', value: 1 }] - } - ] - }; - const result = evaluateMechanicProgram(program, [ - { kind: 'weapon_hit', at: 0, targetId: 'alpha' }, - { kind: 'weapon_hit', at: 1, targetId: 'alpha' }, - { kind: 'weapon_hit', at: 2, targetId: 'bravo' } - ], custom(3)); - expect(result.status).toBe('OK'); - expect(result.finalState).toContainEqual(expect.objectContaining({ - pool: 'stacks', - value: 2 - })); - expect(result.emissions.map((emission) => emission.clauseId)).toEqual([ - 'build-stacks', - 'build-stacks', - 'halve-on-switch', - 'build-stacks' - ]); - }); + it('derives the previous weapon-hit target for target-switch mechanics', () => { + const program: MechanicProgram = { + id: 'target-switch-stacks', + name: 'Target Switch Stacks', + pools: [ + { + key: 'stacks', + label: 'Stacks', + scope: 'agent', + initial: 0, + min: 0, + max: 100, + expiresAfterSeconds: 5, + }, + ], + clauses: [ + { + id: 'halve-on-switch', + when: 'weapon_hit', + if: { + kind: 'all', + predicates: [ + { + kind: 'exists', + value: { source: 'event', path: 'values.previousTargetId' }, + }, + { + kind: 'compare', + left: { source: 'event', path: 'values.previousTargetId' }, + operator: 'ne', + right: { source: 'event', path: 'targetId' }, + }, + ], + }, + reducer: { kind: 'none' }, + mutations: [ + { + kind: 'set', + pool: 'stacks', + value: { + op: 'multiply', + left: { ref: { source: 'pool', pool: 'stacks' } }, + right: 0.5, + }, + }, + ], + }, + { + id: 'build-stacks', + when: 'weapon_hit', + if: { kind: 'exists', value: { source: 'event', path: 'targetId' } }, + reducer: { kind: 'none' }, + mutations: [{ kind: 'add', pool: 'stacks', value: 1 }], + }, + ], + }; + const result = evaluateMechanicProgram( + program, + [ + { kind: 'weapon_hit', at: 0, targetId: 'alpha' }, + { kind: 'weapon_hit', at: 1, targetId: 'alpha' }, + { kind: 'weapon_hit', at: 2, targetId: 'bravo' }, + ], + custom(3), + ); + expect(result.status).toBe('OK'); + expect(result.finalState).toContainEqual( + expect.objectContaining({ + pool: 'stacks', + value: 2, + }), + ); + expect(result.emissions.map((emission) => emission.clauseId)).toEqual([ + 'build-stacks', + 'build-stacks', + 'halve-on-switch', + 'build-stacks', + ]); + }); - it('models Superior Firepower as same-target sliding hits and one shared expiring stack pool', () => { - const superiorFirepower: MechanicProgram = { - id: 'superior-firepower', - name: 'Superior Firepower', - pools: [{ - key: 'relentless', - label: 'Relentless', - scope: 'agent', - initial: 0, - min: 0, - max: 10, - expiresAfterSeconds: 10 - }], - clauses: [{ - id: 'build-relentless', - label: 'Build Relentless', - when: 'weapon_hit', - if: { kind: 'exists', value: { source: 'event', path: 'targetId' } }, - reducer: { - kind: 'sliding_count', - threshold: 9, - windowSeconds: 3, - groupBy: { source: 'event', path: 'targetId' } - }, - mutations: [{ kind: 'add', pool: 'relentless', value: 1 }], - outcomes: [ - { - id: 'relentless-weapon-damage', - label: 'Weapon Damage', - phase: 'while_active', - kind: 'attribute_modifier', - basis: 'flat', - scope: 'equipped_weapon', - attributeId: 'weapon-damage', - value: 3, - statePool: 'relentless', - perStack: true - }, - { - id: 'relentless-critical-damage', - label: 'Critical Damage', - phase: 'while_active', - kind: 'attribute_modifier', - basis: 'flat', - scope: 'equipped_weapon', - attributeId: 'critical-damage', - value: 1, - statePool: 'relentless', - perStack: true - } - ] - }] - }; - const events: MechanicEvent[] = Array.from({ length: 18 }, (_, index) => ({ - kind: 'weapon_hit', - at: index * 0.2, - targetId: 'enemy-a' - })); - const result = evaluateMechanicProgram(superiorFirepower, events, custom(15)); - expect(result.status).toBe('OK'); - expect(result.emissions[0].at).toBeCloseTo(1.6); - expect(result.emissions[1].at).toBeCloseTo(3.4); - expect(result.finalState).toContainEqual(expect.objectContaining({ - pool: 'relentless', - value: 0, - expiresAt: null - })); - expect(result.outcomes.every((outcome) => outcome.attributeId !== null)).toBe(true); - expect(result.outcomes.map((outcome) => outcome.value)).toEqual([3, 1, 6, 2]); - expect(result.script).toContain('COUNT 9 MATCHES WITHIN 3s PER EVENT.targetId'); - expect(result.script).toContain('ADD 1 TO relentless'); - const maximum = evaluateMechanicProgram(superiorFirepower, [], { - mode: 'maximum', - encounterSeconds: 15 - }); - expect(maximum.finalState[0].value).toBe(10); - expect(maximum.outcomes.map((outcome) => outcome.value)).toEqual([30, 10]); - }); + it('models Superior Firepower as same-target sliding hits and one shared expiring stack pool', () => { + const superiorFirepower: MechanicProgram = { + id: 'superior-firepower', + name: 'Superior Firepower', + pools: [ + { + key: 'relentless', + label: 'Relentless', + scope: 'agent', + initial: 0, + min: 0, + max: 10, + expiresAfterSeconds: 10, + }, + ], + clauses: [ + { + id: 'build-relentless', + label: 'Build Relentless', + when: 'weapon_hit', + if: { kind: 'exists', value: { source: 'event', path: 'targetId' } }, + reducer: { + kind: 'sliding_count', + threshold: 9, + windowSeconds: 3, + groupBy: { source: 'event', path: 'targetId' }, + }, + mutations: [{ kind: 'add', pool: 'relentless', value: 1 }], + outcomes: [ + { + id: 'relentless-weapon-damage', + label: 'Weapon Damage', + phase: 'while_active', + kind: 'attribute_modifier', + basis: 'flat', + scope: 'equipped_weapon', + attributeId: 'weapon-damage', + value: 3, + statePool: 'relentless', + perStack: true, + }, + { + id: 'relentless-critical-damage', + label: 'Critical Damage', + phase: 'while_active', + kind: 'attribute_modifier', + basis: 'flat', + scope: 'equipped_weapon', + attributeId: 'critical-damage', + value: 1, + statePool: 'relentless', + perStack: true, + }, + ], + }, + ], + }; + const events: MechanicEvent[] = Array.from({ length: 18 }, (_, index) => ({ + kind: 'weapon_hit', + at: index * 0.2, + targetId: 'enemy-a', + })); + const result = evaluateMechanicProgram( + superiorFirepower, + events, + custom(15), + ); + expect(result.status).toBe('OK'); + expect(result.emissions[0].at).toBeCloseTo(1.6); + expect(result.emissions[1].at).toBeCloseTo(3.4); + expect(result.finalState).toContainEqual( + expect.objectContaining({ + pool: 'relentless', + value: 0, + expiresAt: null, + }), + ); + expect( + result.outcomes.every((outcome) => outcome.attributeId !== null), + ).toBe(true); + expect(result.outcomes.map((outcome) => outcome.value)).toEqual([ + 3, 1, 6, 2, + ]); + expect(result.script).toContain( + 'COUNT 9 MATCHES WITHIN 3s PER EVENT.targetId', + ); + expect(result.script).toContain('ADD 1 TO relentless'); + const maximum = evaluateMechanicProgram(superiorFirepower, [], { + mode: 'maximum', + encounterSeconds: 15, + }); + expect(maximum.finalState[0].value).toBe(10); + expect(maximum.outcomes.map((outcome) => outcome.value)).toEqual([30, 10]); + }); - it('supports every-N-shot reducers without a bespoke action family', () => { - const program: MechanicProgram = { - id: 'every-three-shots', - name: 'Every Three Shots', - pools: [{ key: 'charges', label: 'Charges', scope: 'agent', initial: 0, max: 10 }], - clauses: [{ - id: 'charge', - when: 'shot_fired', - reducer: { kind: 'modulo', every: 3 }, - mutations: [{ kind: 'add', pool: 'charges', value: 1 }] - }] - }; - const events = Array.from({ length: 7 }, (_, index): MechanicEvent => ({ - kind: 'shot_fired', - at: index - })); - const result = evaluateMechanicProgram(program, events, custom(10)); - expect(result.emissions.map((emission) => emission.at)).toEqual([2, 5]); - expect(result.finalState[0].value).toBe(2); - }); + it('supports every-N-shot reducers without a bespoke action family', () => { + const program: MechanicProgram = { + id: 'every-three-shots', + name: 'Every Three Shots', + pools: [ + { + key: 'charges', + label: 'Charges', + scope: 'agent', + initial: 0, + max: 10, + }, + ], + clauses: [ + { + id: 'charge', + when: 'shot_fired', + reducer: { kind: 'modulo', every: 3 }, + mutations: [{ kind: 'add', pool: 'charges', value: 1 }], + }, + ], + }; + const events = Array.from({ length: 7 }, (_, index): MechanicEvent => ({ + kind: 'shot_fired', + at: index, + })); + const result = evaluateMechanicProgram(program, events, custom(10)); + expect(result.emissions.map((emission) => emission.at)).toEqual([2, 5]); + expect(result.finalState[0].value).toBe(2); + }); - it('supports duration and periodic reducers over deterministic timeline snapshots', () => { - const program: MechanicProgram = { - id: 'timed-movement', - name: 'Timed Movement', - pools: [ - { key: 'settled', label: 'Settled', scope: 'agent', initial: 0, max: 1 }, - { key: 'tempo', label: 'Tempo', scope: 'agent', initial: 0, max: 10 } - ], - clauses: [ - { - id: 'immobile-duration', - when: 'movement_snapshot', - if: { - kind: 'compare', - left: { source: 'event', path: 'values.state' }, - operator: 'eq', - right: 'immobile' - }, - reducer: { kind: 'duration', seconds: 3 }, - mutations: [{ kind: 'set', pool: 'settled', value: 1 }] - }, - { - id: 'moving-periodic', - when: 'movement_snapshot', - if: { - kind: 'compare', - left: { source: 'event', path: 'values.state' }, - operator: 'eq', - right: 'moving' - }, - reducer: { kind: 'periodic', intervalSeconds: 2 }, - mutations: [{ kind: 'add', pool: 'tempo', value: 1 }] - } - ] - }; - const result = evaluateMechanicProgram(program, [ - { kind: 'movement_snapshot', at: 0, values: { state: 'immobile' } }, - { kind: 'movement_snapshot', at: 2, values: { state: 'immobile' } }, - { kind: 'movement_snapshot', at: 3, values: { state: 'immobile' } }, - { kind: 'movement_snapshot', at: 5, values: { state: 'moving' } }, - { kind: 'movement_snapshot', at: 7, values: { state: 'moving' } }, - { kind: 'movement_snapshot', at: 9, values: { state: 'moving' } } - ], custom(12)); - expect(result.emissions.map(({ clauseId, at }) => ({ clauseId, at }))).toEqual([ - { clauseId: 'immobile-duration', at: 3 }, - { clauseId: 'moving-periodic', at: 7 }, - { clauseId: 'moving-periodic', at: 9 } - ]); - expect(result.finalState).toEqual([ - expect.objectContaining({ pool: 'settled', value: 1 }), - expect.objectContaining({ pool: 'tempo', value: 2 }) - ]); - }); + it('supports duration and periodic reducers over deterministic timeline snapshots', () => { + const program: MechanicProgram = { + id: 'timed-movement', + name: 'Timed Movement', + pools: [ + { + key: 'settled', + label: 'Settled', + scope: 'agent', + initial: 0, + max: 1, + }, + { key: 'tempo', label: 'Tempo', scope: 'agent', initial: 0, max: 10 }, + ], + clauses: [ + { + id: 'immobile-duration', + when: 'movement_snapshot', + if: { + kind: 'compare', + left: { source: 'event', path: 'values.state' }, + operator: 'eq', + right: 'immobile', + }, + reducer: { kind: 'duration', seconds: 3 }, + mutations: [{ kind: 'set', pool: 'settled', value: 1 }], + }, + { + id: 'moving-periodic', + when: 'movement_snapshot', + if: { + kind: 'compare', + left: { source: 'event', path: 'values.state' }, + operator: 'eq', + right: 'moving', + }, + reducer: { kind: 'periodic', intervalSeconds: 2 }, + mutations: [{ kind: 'add', pool: 'tempo', value: 1 }], + }, + ], + }; + const result = evaluateMechanicProgram( + program, + [ + { kind: 'movement_snapshot', at: 0, values: { state: 'immobile' } }, + { kind: 'movement_snapshot', at: 2, values: { state: 'immobile' } }, + { kind: 'movement_snapshot', at: 3, values: { state: 'immobile' } }, + { kind: 'movement_snapshot', at: 5, values: { state: 'moving' } }, + { kind: 'movement_snapshot', at: 7, values: { state: 'moving' } }, + { kind: 'movement_snapshot', at: 9, values: { state: 'moving' } }, + ], + custom(12), + ); + expect( + result.emissions.map(({ clauseId, at }) => ({ clauseId, at })), + ).toEqual([ + { clauseId: 'immobile-duration', at: 3 }, + { clauseId: 'moving-periodic', at: 7 }, + { clauseId: 'moving-periodic', at: 9 }, + ]); + expect(result.finalState).toEqual([ + expect.objectContaining({ pool: 'settled', value: 1 }), + expect.objectContaining({ pool: 'tempo', value: 2 }), + ]); + }); - it('emits outcomes in the declared event and state-transition phases', () => { - const program: MechanicProgram = { - id: 'phases', - name: 'Outcome Phases', - pools: [{ key: 'active', label: 'Active', scope: 'agent', initial: 0, max: 1 }], - clauses: [ - { - id: 'activate', - when: 'weapon_hit', - reducer: { kind: 'none' }, - mutations: [{ kind: 'set', pool: 'active', value: 1 }], - outcomes: [ - { - id: 'triggering-hit', - label: 'Triggering Hit', - phase: 'on_event', - kind: 'marker', - basis: 'flat', - scope: 'equipped_weapon', - value: 1 - }, - { - id: 'entered', - label: 'Entered', - phase: 'on_enter', - kind: 'marker', - basis: 'flat', - scope: 'self', - statePool: 'active', - value: 1 - }, - { - id: 'active-modifier', - label: 'Active Modifier', - phase: 'while_active', - kind: 'attribute_modifier', - basis: 'flat', - scope: 'equipped_weapon', - attributeId: 'weapon-damage', - statePool: 'active', - value: 10 - } - ] - }, - { - id: 'deactivate', - when: 'firing_stopped', - reducer: { kind: 'none' }, - mutations: [{ kind: 'clear', pool: 'active' }], - outcomes: [{ - id: 'exited', - label: 'Exited', - phase: 'on_exit', - kind: 'marker', - basis: 'flat', - scope: 'self', - statePool: 'active', - value: 1 - }] - } - ] - }; - const result = evaluateMechanicProgram(program, [ - { kind: 'weapon_hit', at: 1, targetId: 'enemy-a' }, - { kind: 'firing_stopped', at: 2 } - ], custom()); - expect(result.outcomes.map((outcome) => `${outcome.outcomeId}:${outcome.phase}`)).toEqual([ - 'triggering-hit:on_event', - 'entered:on_enter', - 'active-modifier:while_active', - 'exited:on_exit' - ]); - }); + it('emits outcomes in the declared event and state-transition phases', () => { + const program: MechanicProgram = { + id: 'phases', + name: 'Outcome Phases', + pools: [ + { key: 'active', label: 'Active', scope: 'agent', initial: 0, max: 1 }, + ], + clauses: [ + { + id: 'activate', + when: 'weapon_hit', + reducer: { kind: 'none' }, + mutations: [{ kind: 'set', pool: 'active', value: 1 }], + outcomes: [ + { + id: 'triggering-hit', + label: 'Triggering Hit', + phase: 'on_event', + kind: 'marker', + basis: 'flat', + scope: 'equipped_weapon', + value: 1, + }, + { + id: 'entered', + label: 'Entered', + phase: 'on_enter', + kind: 'marker', + basis: 'flat', + scope: 'self', + statePool: 'active', + value: 1, + }, + { + id: 'active-modifier', + label: 'Active Modifier', + phase: 'while_active', + kind: 'attribute_modifier', + basis: 'flat', + scope: 'equipped_weapon', + attributeId: 'weapon-damage', + statePool: 'active', + value: 10, + }, + ], + }, + { + id: 'deactivate', + when: 'firing_stopped', + reducer: { kind: 'none' }, + mutations: [{ kind: 'clear', pool: 'active' }], + outcomes: [ + { + id: 'exited', + label: 'Exited', + phase: 'on_exit', + kind: 'marker', + basis: 'flat', + scope: 'self', + statePool: 'active', + value: 1, + }, + ], + }, + ], + }; + const result = evaluateMechanicProgram( + program, + [ + { kind: 'weapon_hit', at: 1, targetId: 'enemy-a' }, + { kind: 'firing_stopped', at: 2 }, + ], + custom(), + ); + expect( + result.outcomes.map((outcome) => `${outcome.outcomeId}:${outcome.phase}`), + ).toEqual([ + 'triggering-hit:on_event', + 'entered:on_enter', + 'active-modifier:while_active', + 'exited:on_exit', + ]); + }); - it('emits on-exit outcomes at pool expiry and applies their resource side effects', () => { - const program: MechanicProgram = { - id: 'expiry-exit', - name: 'Expiry Exit', - pools: [{ - key: 'active', - label: 'Active', - scope: 'agent', - initial: 0, - max: 1, - expiresAfterSeconds: 2 - }], - clauses: [{ - id: 'activate', - when: 'weapon_hit', - reducer: { kind: 'none' }, - mutations: [{ kind: 'set', pool: 'active', value: 1 }], - outcomes: [{ - id: 'expiry-health-cost', - label: 'Expiry Health Cost', - phase: 'on_exit', - kind: 'resource_cost', - scope: 'self', - resource: 'health', - basis: 'max_health_percent', - statePool: 'active', - value: 10 - }] - }] - }; - const result = evaluateMechanicProgram( - program, - [{ kind: 'weapon_hit', at: 1, targetId: 'enemy-a' }], - custom(10), - { maxHealth: 100, currentHealth: 100 } - ); - expect(result.outcomes).toMatchObject([{ - outcomeId: 'expiry-health-cost', - phase: 'on_exit', - at: 3, - appliedValue: 10, - combatBefore: { currentHealth: 100 }, - combatAfter: { currentHealth: 90 } - }]); - expect(result.finalCombat?.currentHealth).toBe(90); - expect(result.finalState).toContainEqual(expect.objectContaining({ - pool: 'active', - value: 0, - expiresAt: null - })); - }); + it('emits on-exit outcomes at pool expiry and applies their resource side effects', () => { + const program: MechanicProgram = { + id: 'expiry-exit', + name: 'Expiry Exit', + pools: [ + { + key: 'active', + label: 'Active', + scope: 'agent', + initial: 0, + max: 1, + expiresAfterSeconds: 2, + }, + ], + clauses: [ + { + id: 'activate', + when: 'weapon_hit', + reducer: { kind: 'none' }, + mutations: [{ kind: 'set', pool: 'active', value: 1 }], + outcomes: [ + { + id: 'expiry-health-cost', + label: 'Expiry Health Cost', + phase: 'on_exit', + kind: 'resource_cost', + scope: 'self', + resource: 'health', + basis: 'max_health_percent', + statePool: 'active', + value: 10, + }, + ], + }, + ], + }; + const result = evaluateMechanicProgram( + program, + [{ kind: 'weapon_hit', at: 1, targetId: 'enemy-a' }], + custom(10), + { maxHealth: 100, currentHealth: 100 }, + ); + expect(result.outcomes).toMatchObject([ + { + outcomeId: 'expiry-health-cost', + phase: 'on_exit', + at: 3, + appliedValue: 10, + combatBefore: { currentHealth: 100 }, + combatAfter: { currentHealth: 90 }, + }, + ]); + expect(result.finalCombat?.currentHealth).toBe(90); + expect(result.finalState).toContainEqual( + expect.objectContaining({ + pool: 'active', + value: 0, + expiresAt: null, + }), + ); + }); - it('uses a custom percentage as a weighted estimate and resolves 100% in maximum mode', () => { - const program: MechanicProgram = { - id: 'full-health-hit', - name: 'Full Health Hit', - pools: [{ - key: 'estimated-procs', - label: 'Estimated Procs', - scope: 'agent', - initial: 0 - }], - scenarioInputs: [{ - key: 'fullHealthHitPercent', - label: 'Hits received at full Health', - kind: 'slider', - defaultValue: 25, - bestCaseValue: 100, - min: 0, - max: 100 - }], - clauses: [{ - id: 'estimate-full-health-hits', - when: 'agent_hit', - reducer: { kind: 'none' }, - weight: { - op: 'divide', - left: { ref: { source: 'scenario', key: 'fullHealthHitPercent' } }, - right: 100 - }, - mutations: [{ kind: 'add', pool: 'estimated-procs', value: 1 }] - }] - }; - const events: MechanicEvent[] = [0, 5, 10, 15].map((at) => ({ kind: 'agent_hit', at })); - const estimate = evaluateMechanicProgram(program, events, custom(20, { fullHealthHitPercent: 25 })); - expect(estimate.estimatedActivationCount).toBe(1); - expect(estimate.emissions.every((emission) => emission.weight === 0.25)).toBe(true); - expect(estimate.emissions.map((emission) => emission.mutations[0].after)).toEqual([ - 0.25, - 0.5, - 0.75, - 1 - ]); - expect(estimate.finalState[0].value).toBe(1); - const maximum = evaluateMechanicProgram(program, events, { mode: 'maximum', encounterSeconds: 20 }); - expect(maximum.estimatedActivationCount).toBe(4); - expect(maximum.scenarioInputs.fullHealthHitPercent).toBe(100); - expect(maximum.finalState[0].value).toBe(4); - }); + it('uses a custom percentage as a weighted estimate and resolves 100% in maximum mode', () => { + const program: MechanicProgram = { + id: 'full-health-hit', + name: 'Full Health Hit', + pools: [ + { + key: 'estimated-procs', + label: 'Estimated Procs', + scope: 'agent', + initial: 0, + }, + ], + scenarioInputs: [ + { + key: 'fullHealthHitPercent', + label: 'Hits received at full Health', + kind: 'slider', + defaultValue: 25, + bestCaseValue: 100, + min: 0, + max: 100, + }, + ], + clauses: [ + { + id: 'estimate-full-health-hits', + when: 'agent_hit', + reducer: { kind: 'none' }, + weight: { + op: 'divide', + left: { ref: { source: 'scenario', key: 'fullHealthHitPercent' } }, + right: 100, + }, + mutations: [{ kind: 'add', pool: 'estimated-procs', value: 1 }], + }, + ], + }; + const events: MechanicEvent[] = [0, 5, 10, 15].map((at) => ({ + kind: 'agent_hit', + at, + })); + const estimate = evaluateMechanicProgram( + program, + events, + custom(20, { fullHealthHitPercent: 25 }), + ); + expect(estimate.estimatedActivationCount).toBe(1); + expect( + estimate.emissions.every((emission) => emission.weight === 0.25), + ).toBe(true); + expect( + estimate.emissions.map((emission) => emission.mutations[0].after), + ).toEqual([0.25, 0.5, 0.75, 1]); + expect(estimate.finalState[0].value).toBe(1); + const maximum = evaluateMechanicProgram(program, events, { + mode: 'maximum', + encounterSeconds: 20, + }); + expect(maximum.estimatedActivationCount).toBe(4); + expect(maximum.scenarioInputs.fullHealthHitPercent).toBe(100); + expect(maximum.finalState[0].value).toBe(4); + }); - it('derives proximity stacks directly from a builder-controlled snapshot', () => { - const program: MechanicProgram = { - id: 'nearby-allies', - name: 'Nearby Allies', - pools: [{ - key: 'nearby-ally-stacks', - label: 'Nearby Ally Stacks', - scope: 'agent', - initial: 0, - min: 0, - max: 3, - bestCaseValue: 3 - }], - clauses: [{ - id: 'derive-nearby-allies', - when: 'proximity_snapshot', - reducer: { - kind: 'derived_scalar', - value: { ref: { source: 'event', path: 'values.alliesWithinRadius' } } - }, - mutations: [{ - kind: 'derive', - pool: 'nearby-ally-stacks', - value: { ref: { source: 'reducer' } } - }] - }] - }; - const result = evaluateMechanicProgram(program, [{ - kind: 'proximity_snapshot', - at: 0, - values: { alliesWithinRadius: 2 } - }], custom()); - expect(result.finalState[0].value).toBe(2); - const maximum = evaluateMechanicProgram(program, [], { mode: 'maximum', encounterSeconds: 30 }); - expect(maximum.finalState[0].value).toBe(3); - }); + it('derives proximity stacks directly from a builder-controlled snapshot', () => { + const program: MechanicProgram = { + id: 'nearby-allies', + name: 'Nearby Allies', + pools: [ + { + key: 'nearby-ally-stacks', + label: 'Nearby Ally Stacks', + scope: 'agent', + initial: 0, + min: 0, + max: 3, + bestCaseValue: 3, + }, + ], + clauses: [ + { + id: 'derive-nearby-allies', + when: 'proximity_snapshot', + reducer: { + kind: 'derived_scalar', + value: { + ref: { source: 'event', path: 'values.alliesWithinRadius' }, + }, + }, + mutations: [ + { + kind: 'derive', + pool: 'nearby-ally-stacks', + value: { ref: { source: 'reducer' } }, + }, + ], + }, + ], + }; + const result = evaluateMechanicProgram( + program, + [ + { + kind: 'proximity_snapshot', + at: 0, + values: { alliesWithinRadius: 2 }, + }, + ], + custom(), + ); + expect(result.finalState[0].value).toBe(2); + const maximum = evaluateMechanicProgram(program, [], { + mode: 'maximum', + encounterSeconds: 30, + }); + expect(maximum.finalState[0].value).toBe(3); + }); - it('shares one movement charge pool across generation and consumption clauses', () => { - const program: MechanicProgram = { - id: 'mobile-charge', - name: 'Mobile Charge', - pools: [{ - key: 'mobile-charge', - label: 'Mobile Charge', - scope: 'agent', - initial: 0, - min: 0, - max: 20 - }], - clauses: [ - { - id: 'gain-moving', - when: 'movement_snapshot', - if: { - kind: 'compare', - left: { source: 'event', path: 'values.state' }, - operator: 'eq', - right: 'moving' - }, - reducer: { kind: 'none' }, - mutations: [{ kind: 'add', pool: 'mobile-charge', value: 1 }] - }, - { - id: 'gain-sprinting', - when: 'movement_snapshot', - if: { - kind: 'compare', - left: { source: 'event', path: 'values.state' }, - operator: 'eq', - right: 'sprinting' - }, - reducer: { kind: 'none' }, - mutations: [{ kind: 'add', pool: 'mobile-charge', value: 2 }] - }, - { - id: 'spend-on-shot', - when: 'shot_fired', - if: { - kind: 'compare', - left: { source: 'pool', pool: 'mobile-charge' }, - operator: 'gt', - right: 0 - }, - reducer: { kind: 'none' }, - mutations: [{ kind: 'consume', pool: 'mobile-charge', value: 1 }] - }, - { - id: 'spend-on-skill', - when: 'skill_deployed', - reducer: { kind: 'none' }, - mutations: [{ kind: 'consume', pool: 'mobile-charge', value: 10 }] - } - ] - }; - const result = evaluateMechanicProgram(program, [ - { kind: 'movement_snapshot', at: 0, values: { state: 'moving' } }, - { kind: 'movement_snapshot', at: 1, values: { state: 'sprinting' } }, - { kind: 'movement_snapshot', at: 2, values: { state: 'sprinting' } }, - { kind: 'shot_fired', at: 3 }, - { kind: 'skill_deployed', at: 4 } - ], custom()); - expect(result.finalState[0].value).toBe(0); - expect(result.emissions.map((emission) => emission.clauseId)).toEqual([ - 'gain-moving', - 'gain-sprinting', - 'gain-sprinting', - 'spend-on-shot', - 'spend-on-skill' - ]); - }); + it('shares one movement charge pool across generation and consumption clauses', () => { + const program: MechanicProgram = { + id: 'mobile-charge', + name: 'Mobile Charge', + pools: [ + { + key: 'mobile-charge', + label: 'Mobile Charge', + scope: 'agent', + initial: 0, + min: 0, + max: 20, + }, + ], + clauses: [ + { + id: 'gain-moving', + when: 'movement_snapshot', + if: { + kind: 'compare', + left: { source: 'event', path: 'values.state' }, + operator: 'eq', + right: 'moving', + }, + reducer: { kind: 'none' }, + mutations: [{ kind: 'add', pool: 'mobile-charge', value: 1 }], + }, + { + id: 'gain-sprinting', + when: 'movement_snapshot', + if: { + kind: 'compare', + left: { source: 'event', path: 'values.state' }, + operator: 'eq', + right: 'sprinting', + }, + reducer: { kind: 'none' }, + mutations: [{ kind: 'add', pool: 'mobile-charge', value: 2 }], + }, + { + id: 'spend-on-shot', + when: 'shot_fired', + if: { + kind: 'compare', + left: { source: 'pool', pool: 'mobile-charge' }, + operator: 'gt', + right: 0, + }, + reducer: { kind: 'none' }, + mutations: [{ kind: 'consume', pool: 'mobile-charge', value: 1 }], + }, + { + id: 'spend-on-skill', + when: 'skill_deployed', + reducer: { kind: 'none' }, + mutations: [{ kind: 'consume', pool: 'mobile-charge', value: 10 }], + }, + ], + }; + const result = evaluateMechanicProgram( + program, + [ + { kind: 'movement_snapshot', at: 0, values: { state: 'moving' } }, + { kind: 'movement_snapshot', at: 1, values: { state: 'sprinting' } }, + { kind: 'movement_snapshot', at: 2, values: { state: 'sprinting' } }, + { kind: 'shot_fired', at: 3 }, + { kind: 'skill_deployed', at: 4 }, + ], + custom(), + ); + expect(result.finalState[0].value).toBe(0); + expect(result.emissions.map((emission) => emission.clauseId)).toEqual([ + 'gain-moving', + 'gain-sprinting', + 'gain-sprinting', + 'spend-on-shot', + 'spend-on-skill', + ]); + }); - it('transfers a target-scoped mark to the target named by the death event', () => { - const program: MechanicProgram = { - id: 'transferring-mark', - name: 'Transferring Mark', - pools: [{ - key: 'mark', - label: 'Mark', - scope: 'target', - initial: 0, - min: 0, - max: 1 - }], - clauses: [ - { - id: 'apply-mark', - when: 'weapon_hit', - reducer: { kind: 'none' }, - mutations: [{ - kind: 'set', - pool: 'mark', - entity: { kind: 'event_target' }, - value: 1 - }] - }, - { - id: 'transfer-mark', - when: 'enemy_killed', - if: { - kind: 'compare', - left: { source: 'pool', pool: 'mark', entity: { kind: 'event_target' } }, - operator: 'gt', - right: 0 - }, - reducer: { kind: 'none' }, - mutations: [{ - kind: 'transfer', - pool: 'mark', - from: { kind: 'event_target' }, - to: { kind: 'event_field', path: 'values.transferTargetId' }, - value: 'all' - }] - } - ] - }; - const result = evaluateMechanicProgram(program, [ - { kind: 'weapon_hit', at: 1, targetId: 'enemy-a' }, - { - kind: 'enemy_killed', - at: 2, - targetId: 'enemy-a', - values: { transferTargetId: 'enemy-b' } - } - ], custom()); - expect(result.finalState).toEqual([ - expect.objectContaining({ entityId: 'enemy-a', value: 0 }), - expect.objectContaining({ entityId: 'enemy-b', value: 1 }) - ]); - }); + it('transfers a target-scoped mark to the target named by the death event', () => { + const program: MechanicProgram = { + id: 'transferring-mark', + name: 'Transferring Mark', + pools: [ + { + key: 'mark', + label: 'Mark', + scope: 'target', + initial: 0, + min: 0, + max: 1, + }, + ], + clauses: [ + { + id: 'apply-mark', + when: 'weapon_hit', + reducer: { kind: 'none' }, + mutations: [ + { + kind: 'set', + pool: 'mark', + entity: { kind: 'event_target' }, + value: 1, + }, + ], + }, + { + id: 'transfer-mark', + when: 'enemy_killed', + if: { + kind: 'compare', + left: { + source: 'pool', + pool: 'mark', + entity: { kind: 'event_target' }, + }, + operator: 'gt', + right: 0, + }, + reducer: { kind: 'none' }, + mutations: [ + { + kind: 'transfer', + pool: 'mark', + from: { kind: 'event_target' }, + to: { kind: 'event_field', path: 'values.transferTargetId' }, + value: 'all', + }, + ], + }, + ], + }; + const result = evaluateMechanicProgram( + program, + [ + { kind: 'weapon_hit', at: 1, targetId: 'enemy-a' }, + { + kind: 'enemy_killed', + at: 2, + targetId: 'enemy-a', + values: { transferTargetId: 'enemy-b' }, + }, + ], + custom(), + ); + expect(result.finalState).toEqual([ + expect.objectContaining({ entityId: 'enemy-a', value: 0 }), + expect.objectContaining({ entityId: 'enemy-b', value: 1 }), + ]); + }); - it('caps and refreshes target-scoped stacks independently', () => { - const program: MechanicProgram = { - id: 'target-trauma', - name: 'Target Trauma', - pools: [{ - key: 'trauma', - label: 'Trauma', - scope: 'target', - initial: 0, - min: 0, - max: 10, - expiresAfterSeconds: 1 - }], - clauses: [ - { - id: 'body-stack', - when: 'weapon_hit', - if: { - kind: 'compare', - left: { source: 'event', path: 'values.headshot' }, - operator: 'eq', - right: false - }, - reducer: { kind: 'none' }, - mutations: [{ - kind: 'add', - pool: 'trauma', - entity: { kind: 'event_target' }, - value: 1 - }] - }, - { - id: 'head-stack', - when: 'weapon_hit', - if: { - kind: 'compare', - left: { source: 'event', path: 'values.headshot' }, - operator: 'eq', - right: true - }, - reducer: { kind: 'none' }, - mutations: [{ - kind: 'add', - pool: 'trauma', - entity: { kind: 'event_target' }, - value: 2 - }] - } - ] - }; - const result = evaluateMechanicProgram(program, [ - { kind: 'weapon_hit', at: 0, targetId: 'enemy-a', values: { headshot: false } }, - { kind: 'weapon_hit', at: 0.25, targetId: 'enemy-b', values: { headshot: false } }, - { kind: 'weapon_hit', at: 0.5, targetId: 'enemy-a', values: { headshot: true } }, - ...Array.from({ length: 5 }, (_, index): MechanicEvent => ({ - kind: 'weapon_hit', - at: 0.55 + index * 0.05, - targetId: 'enemy-a', - values: { headshot: true } - })) - ], custom(0.9)); - expect(result.status).toBe('OK'); - expect(result.finalState).toEqual([ - expect.objectContaining({ - pool: 'trauma', - entityId: 'enemy-a', - value: 10, - expiresAt: 1.75 - }), - expect.objectContaining({ - pool: 'trauma', - entityId: 'enemy-b', - value: 1, - expiresAt: 1.25 - }) - ]); - }); + it('caps and refreshes target-scoped stacks independently', () => { + const program: MechanicProgram = { + id: 'target-trauma', + name: 'Target Trauma', + pools: [ + { + key: 'trauma', + label: 'Trauma', + scope: 'target', + initial: 0, + min: 0, + max: 10, + expiresAfterSeconds: 1, + }, + ], + clauses: [ + { + id: 'body-stack', + when: 'weapon_hit', + if: { + kind: 'compare', + left: { source: 'event', path: 'values.headshot' }, + operator: 'eq', + right: false, + }, + reducer: { kind: 'none' }, + mutations: [ + { + kind: 'add', + pool: 'trauma', + entity: { kind: 'event_target' }, + value: 1, + }, + ], + }, + { + id: 'head-stack', + when: 'weapon_hit', + if: { + kind: 'compare', + left: { source: 'event', path: 'values.headshot' }, + operator: 'eq', + right: true, + }, + reducer: { kind: 'none' }, + mutations: [ + { + kind: 'add', + pool: 'trauma', + entity: { kind: 'event_target' }, + value: 2, + }, + ], + }, + ], + }; + const result = evaluateMechanicProgram( + program, + [ + { + kind: 'weapon_hit', + at: 0, + targetId: 'enemy-a', + values: { headshot: false }, + }, + { + kind: 'weapon_hit', + at: 0.25, + targetId: 'enemy-b', + values: { headshot: false }, + }, + { + kind: 'weapon_hit', + at: 0.5, + targetId: 'enemy-a', + values: { headshot: true }, + }, + ...Array.from({ length: 5 }, (_, index): MechanicEvent => ({ + kind: 'weapon_hit', + at: 0.55 + index * 0.05, + targetId: 'enemy-a', + values: { headshot: true }, + })), + ], + custom(0.9), + ); + expect(result.status).toBe('OK'); + expect(result.finalState).toEqual([ + expect.objectContaining({ + pool: 'trauma', + entityId: 'enemy-a', + value: 10, + expiresAt: 1.75, + }), + expect.objectContaining({ + pool: 'trauma', + entityId: 'enemy-b', + value: 1, + expiresAt: 1.25, + }), + ]); + }); - it('uses the prior target stack count for live-Attribute damage on pool exit', () => { - const skillDamageId = 'skill-damage'; - const program: MechanicProgram = { - id: 'precise-trauma', - name: 'Precise Trauma', - pools: [{ - key: 'trauma', - label: 'Trauma', - scope: 'target', - initial: 0, - min: 0, - max: 10, - expiresAfterSeconds: 1 - }], - clauses: [{ - id: 'apply-trauma', - when: 'weapon_hit', - reducer: { kind: 'none' }, - mutations: [{ - kind: 'add', - pool: 'trauma', - entity: { kind: 'event_target' }, - value: 2 - }], - outcomes: [{ - id: 'trauma-damage', - label: 'Trauma Damage', - phase: 'on_exit', - kind: 'direct_damage', - scope: 'target', - basis: 'flat', - value: { ref: { source: 'attribute', attributeId: skillDamageId } }, - statePool: 'trauma', - perStack: true, - minStacks: 2 - }] - }] - }; - const result = evaluateMechanicProgram( - program, - [{ kind: 'weapon_hit', at: 0, targetId: 'enemy-a', values: { headshot: true } }], - custom(2), - { - maxHealth: 100, - currentHealth: 100, - attributes: { [skillDamageId]: 75 } - } - ); - expect(result.status).toBe('OK'); - expect(result.outcomes).toMatchObject([{ - outcomeId: 'trauma-damage', - targetId: 'enemy-a', - at: 1, - phase: 'on_exit', - value: 150, - resolvedValue: 150, - appliedValue: 150, - stateValue: 2 - }]); - expect(result.finalState).toContainEqual(expect.objectContaining({ - pool: 'trauma', - entityId: 'enemy-a', - value: 0, - expiresAt: null - })); - }); + it('uses the prior target stack count for live-Attribute damage on pool exit', () => { + const skillDamageId = 'skill-damage'; + const program: MechanicProgram = { + id: 'precise-trauma', + name: 'Precise Trauma', + pools: [ + { + key: 'trauma', + label: 'Trauma', + scope: 'target', + initial: 0, + min: 0, + max: 10, + expiresAfterSeconds: 1, + }, + ], + clauses: [ + { + id: 'apply-trauma', + when: 'weapon_hit', + reducer: { kind: 'none' }, + mutations: [ + { + kind: 'add', + pool: 'trauma', + entity: { kind: 'event_target' }, + value: 2, + }, + ], + outcomes: [ + { + id: 'trauma-damage', + label: 'Trauma Damage', + phase: 'on_exit', + kind: 'direct_damage', + scope: 'target', + basis: 'flat', + value: { + ref: { source: 'attribute', attributeId: skillDamageId }, + }, + statePool: 'trauma', + perStack: true, + minStacks: 2, + }, + ], + }, + ], + }; + const result = evaluateMechanicProgram( + program, + [ + { + kind: 'weapon_hit', + at: 0, + targetId: 'enemy-a', + values: { headshot: true }, + }, + ], + custom(2), + { + maxHealth: 100, + currentHealth: 100, + attributes: { [skillDamageId]: 75 }, + }, + ); + expect(result.status).toBe('OK'); + expect(result.outcomes).toMatchObject([ + { + outcomeId: 'trauma-damage', + targetId: 'enemy-a', + at: 1, + phase: 'on_exit', + value: 150, + resolvedValue: 150, + appliedValue: 150, + stateValue: 2, + }, + ]); + expect(result.finalState).toContainEqual( + expect.objectContaining({ + pool: 'trauma', + entityId: 'enemy-a', + value: 0, + expiresAt: null, + }), + ); + }); - it('models scaled OS Protocol restoration, an unscaled Health cost, and post-cost bonus damage', () => { - const upgradeScale = { - op: 'add' as const, - left: 1, - right: { - op: 'divide' as const, - left: { ref: { source: 'equipment' as const, path: 'protocolTalentUpgrades' } }, - right: 5 - } - }; - const program: MechanicProgram = { - id: 'os-protocol-health-engine', - name: 'OS Protocol Health Engine', - scenarioInputs: [{ - key: 'restorationIntervalSeconds', - label: 'Restoration interval', - description: 'The game cadence is not yet verified; Custom Scenario must supply it.', - kind: 'number', - readiness: 'requires_assumption', - min: 0.01 - }], - pools: [{ - key: 'nearby-enemies', - label: 'Nearby Enemies', - scope: 'agent', - initial: 0, - min: 0, - max: 20 - }], - clauses: [ - { - id: 'sample-nearby-enemies', - when: 'proximity_snapshot', - reducer: { - kind: 'derived_scalar', - value: { ref: { source: 'event', path: 'values.enemiesWithinRadius' } } - }, - mutations: [{ - kind: 'derive', - pool: 'nearby-enemies', - value: { ref: { source: 'reducer' } } - }] - }, - { - id: 'restore-nearby-enemies', - when: 'tick', - reducer: { - kind: 'periodic', - intervalSeconds: { - ref: { - source: 'scenario', - key: 'restorationIntervalSeconds' - } - } - }, - mutations: [{ kind: 'none' }], - outcomes: [{ - id: 'nearby-restoration', - label: 'Nearby Enemy Restoration', - phase: 'after_event', - kind: 'restoration', - scope: 'self', - resource: 'health', - basis: 'max_health_percent', - value: { - op: 'multiply', - left: { - op: 'clamp', - value: { - op: 'multiply', - left: { ref: { source: 'pool', pool: 'nearby-enemies' } }, - right: 0.5 - }, - min: 0, - max: 2.5 - }, - right: upgradeScale - } - }] - }, - { - id: 'shot-health-exchange', - when: 'shot_fired', - reducer: { kind: 'none' }, - mutations: [{ kind: 'none' }], - outcomes: [ - { - id: 'shot-health-cost', - label: 'Shot Health Cost', - phase: 'on_event', - kind: 'resource_cost', - scope: 'self', - resource: 'health', - basis: 'max_health_percent', - value: 2 - }, - { - id: 'shot-health-damage', - label: 'Current Health Bonus Damage', - phase: 'on_event', - kind: 'direct_damage', - scope: 'target', - basis: 'current_health_percent', - combatTiming: 'current', - value: { - op: 'multiply', - left: 0.6, - right: upgradeScale - } - } - ] - } - ] - }; - const events: MechanicEvent[] = [ - { kind: 'tick', at: 0 }, - { kind: 'shot_fired', at: 1 }, - { kind: 'proximity_snapshot', at: 2, values: { enemiesWithinRadius: 9 } }, - { kind: 'tick', at: 3 } - ]; - const result = evaluateMechanicProgram( - program, - events, - custom(10, { restorationIntervalSeconds: 3 }), - { maxHealth: 10_000, currentHealth: 10_000, weaponBaseDamage: 500 }, - { protocolTalentUpgrades: 5 } - ); - expect(result.status).toBe('OK'); - const cost = result.outcomes.find((outcome) => outcome.outcomeId === 'shot-health-cost'); - const damage = result.outcomes.find((outcome) => outcome.outcomeId === 'shot-health-damage'); - const restoration = result.outcomes.find((outcome) => outcome.outcomeId === 'nearby-restoration'); - expect(cost).toMatchObject({ - value: 2, - resolvedValue: 200, - appliedValue: 200 - }); - expect(damage).toMatchObject({ - value: 1.2, - resolvedValue: 117.6, - appliedValue: 117.6 - }); - expect(restoration).toMatchObject({ - value: 5, - resolvedValue: 500, - appliedValue: 200 - }); - expect(result.finalCombat?.currentHealth).toBe(10_000); - expect(result.equipment?.protocolTalentUpgrades).toBe(5); + it('models scaled OS Protocol restoration, an unscaled Health cost, and post-cost bonus damage', () => { + const upgradeScale = { + op: 'add' as const, + left: 1, + right: { + op: 'divide' as const, + left: { + ref: { source: 'equipment' as const, path: 'protocolTalentUpgrades' }, + }, + right: 5, + }, + }; + const program: MechanicProgram = { + id: 'os-protocol-health-engine', + name: 'OS Protocol Health Engine', + scenarioInputs: [ + { + key: 'restorationIntervalSeconds', + label: 'Restoration interval', + description: + 'The game cadence is not yet verified; Custom Scenario must supply it.', + kind: 'number', + readiness: 'requires_assumption', + min: 0.01, + }, + ], + pools: [ + { + key: 'nearby-enemies', + label: 'Nearby Enemies', + scope: 'agent', + initial: 0, + min: 0, + max: 20, + }, + ], + clauses: [ + { + id: 'sample-nearby-enemies', + when: 'proximity_snapshot', + reducer: { + kind: 'derived_scalar', + value: { + ref: { source: 'event', path: 'values.enemiesWithinRadius' }, + }, + }, + mutations: [ + { + kind: 'derive', + pool: 'nearby-enemies', + value: { ref: { source: 'reducer' } }, + }, + ], + }, + { + id: 'restore-nearby-enemies', + when: 'tick', + reducer: { + kind: 'periodic', + intervalSeconds: { + ref: { + source: 'scenario', + key: 'restorationIntervalSeconds', + }, + }, + }, + mutations: [{ kind: 'none' }], + outcomes: [ + { + id: 'nearby-restoration', + label: 'Nearby Enemy Restoration', + phase: 'after_event', + kind: 'restoration', + scope: 'self', + resource: 'health', + basis: 'max_health_percent', + value: { + op: 'multiply', + left: { + op: 'clamp', + value: { + op: 'multiply', + left: { ref: { source: 'pool', pool: 'nearby-enemies' } }, + right: 0.5, + }, + min: 0, + max: 2.5, + }, + right: upgradeScale, + }, + }, + ], + }, + { + id: 'shot-health-exchange', + when: 'shot_fired', + reducer: { kind: 'none' }, + mutations: [{ kind: 'none' }], + outcomes: [ + { + id: 'shot-health-cost', + label: 'Shot Health Cost', + phase: 'on_event', + kind: 'resource_cost', + scope: 'self', + resource: 'health', + basis: 'max_health_percent', + value: 2, + }, + { + id: 'shot-health-damage', + label: 'Current Health Bonus Damage', + phase: 'on_event', + kind: 'direct_damage', + scope: 'target', + basis: 'current_health_percent', + combatTiming: 'current', + value: { + op: 'multiply', + left: 0.6, + right: upgradeScale, + }, + }, + ], + }, + ], + }; + const events: MechanicEvent[] = [ + { kind: 'tick', at: 0 }, + { kind: 'shot_fired', at: 1 }, + { kind: 'proximity_snapshot', at: 2, values: { enemiesWithinRadius: 9 } }, + { kind: 'tick', at: 3 }, + ]; + const result = evaluateMechanicProgram( + program, + events, + custom(10, { restorationIntervalSeconds: 3 }), + { maxHealth: 10_000, currentHealth: 10_000, weaponBaseDamage: 500 }, + { protocolTalentUpgrades: 5 }, + ); + expect(result.status).toBe('OK'); + const cost = result.outcomes.find( + (outcome) => outcome.outcomeId === 'shot-health-cost', + ); + const damage = result.outcomes.find( + (outcome) => outcome.outcomeId === 'shot-health-damage', + ); + const restoration = result.outcomes.find( + (outcome) => outcome.outcomeId === 'nearby-restoration', + ); + expect(cost).toMatchObject({ + value: 2, + resolvedValue: 200, + appliedValue: 200, + }); + expect(damage).toMatchObject({ + value: 1.2, + resolvedValue: 117.6, + appliedValue: 117.6, + }); + expect(restoration).toMatchObject({ + value: 5, + resolvedValue: 500, + appliedValue: 200, + }); + expect(result.finalCombat?.currentHealth).toBe(10_000); + expect(result.equipment?.protocolTalentUpgrades).toBe(5); - const preCostProgram = structuredClone(program); - const preCostDamage = preCostProgram.clauses[2].outcomes?.find( - (outcome) => outcome.id === 'shot-health-damage' - ); - if (preCostDamage) preCostDamage.combatTiming = 'pre_event'; - const preCost = evaluateMechanicProgram( - preCostProgram, - [{ kind: 'shot_fired', at: 1 }], - custom(10, { restorationIntervalSeconds: 3 }), - { maxHealth: 10_000, currentHealth: 10_000 }, - { protocolTalentUpgrades: 5 } - ); - expect(preCost.outcomes.find((outcome) => outcome.outcomeId === 'shot-health-damage')) - .toMatchObject({ resolvedValue: 120 }); + const preCostProgram = structuredClone(program); + const preCostDamage = preCostProgram.clauses[2].outcomes?.find( + (outcome) => outcome.id === 'shot-health-damage', + ); + if (preCostDamage) preCostDamage.combatTiming = 'pre_event'; + const preCost = evaluateMechanicProgram( + preCostProgram, + [{ kind: 'shot_fired', at: 1 }], + custom(10, { restorationIntervalSeconds: 3 }), + { maxHealth: 10_000, currentHealth: 10_000 }, + { protocolTalentUpgrades: 5 }, + ); + expect( + preCost.outcomes.find( + (outcome) => outcome.outcomeId === 'shot-health-damage', + ), + ).toMatchObject({ resolvedValue: 120 }); - const unresolved = evaluateMechanicProgram( - program, - [], - { mode: 'maximum', encounterSeconds: 10 }, - { maxHealth: 10_000, currentHealth: 10_000 }, - { protocolTalentUpgrades: 5 } - ); - expect(unresolved.readiness).toEqual({ - status: 'REQUIRES_ASSUMPTIONS', - missingInputs: ['restorationIntervalSeconds'] - }); - }); + const unresolved = evaluateMechanicProgram( + program, + [], + { mode: 'maximum', encounterSeconds: 10 }, + { maxHealth: 10_000, currentHealth: 10_000 }, + { protocolTalentUpgrades: 5 }, + ); + expect(unresolved.readiness).toEqual({ + status: 'REQUIRES_ASSUMPTIONS', + missingInputs: ['restorationIntervalSeconds'], + }); + }); - it('counts distinct values in a rolling window, resets consecutive runs, and applies named resources', () => { - const program: MechanicProgram = { - id: 'runtime-gaps', - name: 'Runtime gaps', - clauses: [ - { - id: 'distinct-targets', - recipeKey: 'distinct-enemies-within-window', - when: 'weapon_hit', - if: { kind: 'exists', value: { source: 'event', path: 'targetId' } }, - reducer: { - kind: 'distinct_count', - threshold: 3, - windowSeconds: 2, - distinctBy: { source: 'event', path: 'targetId' } - }, - mutations: [{ kind: 'none' }], - outcomes: [{ - id: 'distinct-marker', - label: 'Distinct target threshold', - phase: 'after_event', - kind: 'marker', - scope: 'all_skills', - basis: 'flat', - value: 1 - }] - }, - { - id: 'consecutive-targets', - when: 'shot_fired', - if: { kind: 'exists', value: { source: 'event', path: 'targetId' } }, - reducer: { - kind: 'consecutive_count', - threshold: 3, - groupBy: { source: 'event', path: 'targetId' } - }, - mutations: [{ kind: 'none' }], - outcomes: [{ - id: 'consecutive-marker', - label: 'Consecutive target threshold', - phase: 'after_event', - kind: 'marker', - scope: 'assigned_ability', - basis: 'flat', - value: 1 - }] - }, - { - id: 'resources', - when: 'tick', - reducer: { kind: 'none' }, - mutations: [{ kind: 'none' }], - outcomes: [ - { - id: 'health-cost', label: 'Health cost', phase: 'on_event', kind: 'resource_cost', - scope: 'self', resource: 'health', basis: 'flat', value: 10 - }, - { - id: 'armor-restore', label: 'Armor restore', phase: 'on_event', kind: 'restoration', - scope: 'self_and_allies', resource: 'armor', basis: 'flat', value: 20 - }, - { - id: 'ammo-restore', label: 'Ammo restore', phase: 'on_event', kind: 'restoration', - scope: 'equipped_weapon', resource: 'ammo', basis: 'flat', value: 5 - } - ] - } - ] - }; - const result = evaluateMechanicProgram(program, [ - { kind: 'weapon_hit', at: 0, targetId: 'alpha' }, - { kind: 'weapon_hit', at: 0.2, targetId: 'alpha' }, - { kind: 'weapon_hit', at: 0.4, targetId: 'bravo' }, - { kind: 'weapon_hit', at: 0.6, targetId: 'charlie' }, - { kind: 'shot_fired', at: 1, targetId: 'alpha' }, - { kind: 'shot_fired', at: 1.1, targetId: 'alpha' }, - { kind: 'shot_fired', at: 1.2 }, - { kind: 'shot_fired', at: 1.3, targetId: 'bravo' }, - { kind: 'shot_fired', at: 1.4, targetId: 'bravo' }, - { kind: 'shot_fired', at: 1.5, targetId: 'bravo' }, - { kind: 'tick', at: 2 } - ], custom(), { maxHealth: 100, currentHealth: 50, maxArmor: 50, currentArmor: 30, maxAmmo: 10, currentAmmo: 7 }); - expect(result.status).toBe('OK'); - expect(result.emissions.filter(({ clauseId }) => clauseId === 'distinct-targets')).toMatchObject([{ at: 0.6, reducerValue: 3 }]); - expect(result.emissions.filter(({ clauseId }) => clauseId === 'consecutive-targets')).toMatchObject([{ at: 1.5, reducerValue: 3 }]); - expect(result.outcomes.filter(({ clauseId }) => clauseId === 'resources')).toMatchObject([ - { outcomeId: 'health-cost', resource: 'health', appliedValue: 10 }, - { outcomeId: 'armor-restore', resource: 'armor', appliedValue: 20 }, - { outcomeId: 'ammo-restore', resource: 'ammo', appliedValue: 3 } - ]); - expect(result.finalCombat).toMatchObject({ currentHealth: 40, currentArmor: 50, currentAmmo: 10 }); - expect(result.script).toContain('COUNT 3 DISTINCT EVENT.targetId WITHIN 2s'); - expect(result.script).toContain('COUNT 3 CONSECUTIVE MATCHES PER EVENT.targetId'); - expect(result.script).toContain('[RECIPE distinct-enemies-within-window]'); - }); - it('resolves live Attribute percentages and delivers a restoration total continuously', () => { - const firepowerMultiplierId = '82a95611-283b-4fd8-a6ed-3567a316f36e'; - const program: MechanicProgram = { - id: 'predatory-aura', - name: 'Predatory Aura', - clauses: [{ - id: 'restore-on-kill', - when: 'enemy_killed', - reducer: { kind: 'none' }, - mutations: [], - outcomes: [{ - id: 'health-restoration', - label: 'Health restoration', - phase: 'after_event', - kind: 'restoration', - scope: 'self', - resource: 'health', - basis: 'flat', - delivery: 'continuous', - durationSeconds: 3, - value: { - op: 'multiply', - left: 240, - right: { - op: 'divide', - left: { ref: { source: 'attribute', attributeId: firepowerMultiplierId } }, - right: 100 - } - } - }] - }] - }; - const result = evaluateMechanicProgram( - program, - [{ kind: 'enemy_killed', at: 0 }], - custom(3), - { - maxHealth: 1_000, - currentHealth: 500, - attributes: { [firepowerMultiplierId]: 100 } - } - ); - expect(result.status).toBe('OK'); - expect(result.outcomes).toHaveLength(1); - expect(result.outcomes[0]).toMatchObject({ - at: 3, - delivery: 'continuous', - deliveryStart: 0, - deliveryEnd: 3, - totalResolvedValue: 240, - resolvedValue: 240, - appliedValue: 240 - }); - expect(result.finalCombat?.currentHealth).toBe(740); - expect(result.script).toContain( - 'ATTRIBUTE[82a95611-283b-4fd8-a6ed-3567a316f36e] / 100' - ); - expect(result.script).toContain('DELIVER TOTAL CONTINUOUSLY OVER 3s'); + it('counts distinct values in a rolling window, resets consecutive runs, and applies named resources', () => { + const program: MechanicProgram = { + id: 'runtime-gaps', + name: 'Runtime gaps', + clauses: [ + { + id: 'distinct-targets', + recipeKey: 'distinct-enemies-within-window', + when: 'weapon_hit', + if: { kind: 'exists', value: { source: 'event', path: 'targetId' } }, + reducer: { + kind: 'distinct_count', + threshold: 3, + windowSeconds: 2, + distinctBy: { source: 'event', path: 'targetId' }, + }, + mutations: [{ kind: 'none' }], + outcomes: [ + { + id: 'distinct-marker', + label: 'Distinct target threshold', + phase: 'after_event', + kind: 'marker', + scope: 'all_skills', + basis: 'flat', + value: 1, + }, + ], + }, + { + id: 'consecutive-targets', + when: 'shot_fired', + if: { kind: 'exists', value: { source: 'event', path: 'targetId' } }, + reducer: { + kind: 'consecutive_count', + threshold: 3, + groupBy: { source: 'event', path: 'targetId' }, + }, + mutations: [{ kind: 'none' }], + outcomes: [ + { + id: 'consecutive-marker', + label: 'Consecutive target threshold', + phase: 'after_event', + kind: 'marker', + scope: 'assigned_ability', + basis: 'flat', + value: 1, + }, + ], + }, + { + id: 'resources', + when: 'tick', + reducer: { kind: 'none' }, + mutations: [{ kind: 'none' }], + outcomes: [ + { + id: 'health-cost', + label: 'Health cost', + phase: 'on_event', + kind: 'resource_cost', + scope: 'self', + resource: 'health', + basis: 'flat', + value: 10, + }, + { + id: 'armor-restore', + label: 'Armor restore', + phase: 'on_event', + kind: 'restoration', + scope: 'self_and_allies', + resource: 'armor', + basis: 'flat', + value: 20, + }, + { + id: 'ammo-restore', + label: 'Ammo restore', + phase: 'on_event', + kind: 'restoration', + scope: 'equipped_weapon', + resource: 'ammo', + basis: 'flat', + value: 5, + }, + ], + }, + ], + }; + const result = evaluateMechanicProgram( + program, + [ + { kind: 'weapon_hit', at: 0, targetId: 'alpha' }, + { kind: 'weapon_hit', at: 0.2, targetId: 'alpha' }, + { kind: 'weapon_hit', at: 0.4, targetId: 'bravo' }, + { kind: 'weapon_hit', at: 0.6, targetId: 'charlie' }, + { kind: 'shot_fired', at: 1, targetId: 'alpha' }, + { kind: 'shot_fired', at: 1.1, targetId: 'alpha' }, + { kind: 'shot_fired', at: 1.2 }, + { kind: 'shot_fired', at: 1.3, targetId: 'bravo' }, + { kind: 'shot_fired', at: 1.4, targetId: 'bravo' }, + { kind: 'shot_fired', at: 1.5, targetId: 'bravo' }, + { kind: 'tick', at: 2 }, + ], + custom(), + { + maxHealth: 100, + currentHealth: 50, + maxArmor: 50, + currentArmor: 30, + maxAmmo: 10, + currentAmmo: 7, + }, + ); + expect(result.status).toBe('OK'); + expect( + result.emissions.filter( + ({ clauseId }) => clauseId === 'distinct-targets', + ), + ).toMatchObject([{ at: 0.6, reducerValue: 3 }]); + expect( + result.emissions.filter( + ({ clauseId }) => clauseId === 'consecutive-targets', + ), + ).toMatchObject([{ at: 1.5, reducerValue: 3 }]); + expect( + result.outcomes.filter(({ clauseId }) => clauseId === 'resources'), + ).toMatchObject([ + { outcomeId: 'health-cost', resource: 'health', appliedValue: 10 }, + { outcomeId: 'armor-restore', resource: 'armor', appliedValue: 20 }, + { outcomeId: 'ammo-restore', resource: 'ammo', appliedValue: 3 }, + ]); + expect(result.finalCombat).toMatchObject({ + currentHealth: 40, + currentArmor: 50, + currentAmmo: 10, + }); + expect(result.script).toContain( + 'COUNT 3 DISTINCT EVENT.targetId WITHIN 2s', + ); + expect(result.script).toContain( + 'COUNT 3 CONSECUTIVE MATCHES PER EVENT.targetId', + ); + expect(result.script).toContain('[RECIPE distinct-enemies-within-window]'); + }); + it('resolves live Attribute percentages and delivers a restoration total continuously', () => { + const firepowerMultiplierId = '82a95611-283b-4fd8-a6ed-3567a316f36e'; + const program: MechanicProgram = { + id: 'predatory-aura', + name: 'Predatory Aura', + clauses: [ + { + id: 'restore-on-kill', + when: 'enemy_killed', + reducer: { kind: 'none' }, + mutations: [], + outcomes: [ + { + id: 'health-restoration', + label: 'Health restoration', + phase: 'after_event', + kind: 'restoration', + scope: 'self', + resource: 'health', + basis: 'flat', + delivery: 'continuous', + durationSeconds: 3, + value: { + op: 'multiply', + left: 240, + right: { + op: 'divide', + left: { + ref: { + source: 'attribute', + attributeId: firepowerMultiplierId, + }, + }, + right: 100, + }, + }, + }, + ], + }, + ], + }; + const result = evaluateMechanicProgram( + program, + [{ kind: 'enemy_killed', at: 0 }], + custom(3), + { + maxHealth: 1_000, + currentHealth: 500, + attributes: { [firepowerMultiplierId]: 100 }, + }, + ); + expect(result.status).toBe('OK'); + expect(result.outcomes).toHaveLength(1); + expect(result.outcomes[0]).toMatchObject({ + at: 3, + delivery: 'continuous', + deliveryStart: 0, + deliveryEnd: 3, + totalResolvedValue: 240, + resolvedValue: 240, + appliedValue: 240, + }); + expect(result.finalCombat?.currentHealth).toBe(740); + expect(result.script).toContain( + 'ATTRIBUTE[82a95611-283b-4fd8-a6ed-3567a316f36e] / 100', + ); + expect(result.script).toContain('DELIVER TOTAL CONTINUOUSLY OVER 3s'); - const missingAttribute = evaluateMechanicProgram( - program, - [{ kind: 'enemy_killed', at: 0 }], - custom(3), - { maxHealth: 1_000, currentHealth: 500 } - ); - expect(missingAttribute.status).toBe('INVALID'); - expect(missingAttribute.errors).toContain( - `This mechanic requires authoritative Attribute ${firepowerMultiplierId}.` - ); - }); + const missingAttribute = evaluateMechanicProgram( + program, + [{ kind: 'enemy_killed', at: 0 }], + custom(3), + { maxHealth: 1_000, currentHealth: 500 }, + ); + expect(missingAttribute.status).toBe('INVALID'); + expect(missingAttribute.errors).toContain( + `This mechanic requires authoritative Attribute ${firepowerMultiplierId}.`, + ); + }); - it('integrates overlapping restoration streams at event boundaries without banking overheal', () => { - const program: MechanicProgram = { - id: 'continuous-restoration', - name: 'Continuous Restoration', - clauses: [{ - id: 'restore', - when: 'enemy_killed', - reducer: { kind: 'none' }, - mutations: [], - outcomes: [{ - id: 'restore-health', - label: 'Restore Health', - phase: 'after_event', - kind: 'restoration', - scope: 'self', - resource: 'health', - basis: 'flat', - value: 240, - delivery: 'continuous', - durationSeconds: 3 - }] - }, { - id: 'damage-between-streams', - when: 'agent_hit', - reducer: { kind: 'none' }, - mutations: [], - outcomes: [{ - id: 'health-cost', - label: 'Incoming damage proxy', - phase: 'on_event', - kind: 'resource_cost', - scope: 'self', - resource: 'health', - basis: 'flat', - value: 100 - }] - }] - }; - const result = evaluateMechanicProgram( - program, - [ - { kind: 'enemy_killed', at: 0 }, - { kind: 'agent_hit', at: 1 }, - { kind: 'enemy_killed', at: 1 } - ], - custom(4), - { maxHealth: 1_000, currentHealth: 500 } - ); - const continuous = result.outcomes.filter((outcome) => outcome.delivery === 'continuous'); - expect(continuous[0]).toMatchObject({ at: 1, resolvedValue: 80, appliedValue: 80 }); - expect(continuous.reduce((total, outcome) => total + outcome.resolvedValue, 0)).toBeCloseTo(480); - expect(continuous.reduce((total, outcome) => total + outcome.appliedValue, 0)).toBeCloseTo(480); - expect(result.finalCombat?.currentHealth).toBe(880); + it('integrates overlapping restoration streams at event boundaries without banking overheal', () => { + const program: MechanicProgram = { + id: 'continuous-restoration', + name: 'Continuous Restoration', + clauses: [ + { + id: 'restore', + when: 'enemy_killed', + reducer: { kind: 'none' }, + mutations: [], + outcomes: [ + { + id: 'restore-health', + label: 'Restore Health', + phase: 'after_event', + kind: 'restoration', + scope: 'self', + resource: 'health', + basis: 'flat', + value: 240, + delivery: 'continuous', + durationSeconds: 3, + }, + ], + }, + { + id: 'damage-between-streams', + when: 'agent_hit', + reducer: { kind: 'none' }, + mutations: [], + outcomes: [ + { + id: 'health-cost', + label: 'Incoming damage proxy', + phase: 'on_event', + kind: 'resource_cost', + scope: 'self', + resource: 'health', + basis: 'flat', + value: 100, + }, + ], + }, + ], + }; + const result = evaluateMechanicProgram( + program, + [ + { kind: 'enemy_killed', at: 0 }, + { kind: 'agent_hit', at: 1 }, + { kind: 'enemy_killed', at: 1 }, + ], + custom(4), + { maxHealth: 1_000, currentHealth: 500 }, + ); + const continuous = result.outcomes.filter( + (outcome) => outcome.delivery === 'continuous', + ); + expect(continuous[0]).toMatchObject({ + at: 1, + resolvedValue: 80, + appliedValue: 80, + }); + expect( + continuous.reduce((total, outcome) => total + outcome.resolvedValue, 0), + ).toBeCloseTo(480); + expect( + continuous.reduce((total, outcome) => total + outcome.appliedValue, 0), + ).toBeCloseTo(480); + expect(result.finalCombat?.currentHealth).toBe(880); - const capped = evaluateMechanicProgram( - program, - [{ kind: 'enemy_killed', at: 0 }], - custom(3), - { maxHealth: 1_000, currentHealth: 950 } - ); - expect(capped.outcomes.reduce((total, outcome) => total + outcome.appliedValue, 0)).toBe(50); - expect(capped.finalCombat?.currentHealth).toBe(1_000); - }); + const capped = evaluateMechanicProgram( + program, + [{ kind: 'enemy_killed', at: 0 }], + custom(3), + { maxHealth: 1_000, currentHealth: 950 }, + ); + expect( + capped.outcomes.reduce( + (total, outcome) => total + outcome.appliedValue, + 0, + ), + ).toBe(50); + expect(capped.finalCombat?.currentHealth).toBe(1_000); + }); }); describe('mechanic validation and pseudo-script', () => { - it('shares an identical timed modifier outcome across kill and assist refresh clauses', () => { - const weaponDamage = { - id: 'pummel-weapon-damage', - label: 'Weapon Damage', - phase: 'while_active' as const, - kind: 'attribute_modifier' as const, - scope: 'equipped_weapon' as const, - basis: 'flat' as const, - value: 10, - attributeId: 'weapon-damage', - statePool: 'pummel-active' - }; - const program: MechanicProgram = { - id: 'pummel-refresh', - name: 'Pummel Refresh', - pools: [{ - key: 'pummel-active', label: 'Pummel active', scope: 'agent', initial: 0, - min: 0, max: 1, expiresAfterSeconds: 12 - }], - clauses: [ - { - id: 'activate-on-kill', when: 'enemy_killed', reducer: { kind: 'none' }, - mutations: [{ kind: 'set', pool: 'pummel-active', value: 1 }], - outcomes: [weaponDamage] - }, - { - id: 'refresh-on-assist', when: 'assist', reducer: { kind: 'none' }, - mutations: [{ kind: 'set', pool: 'pummel-active', value: 1 }], - outcomes: [{ ...weaponDamage }] - } - ] - }; + it('shares an identical timed modifier outcome across kill and assist refresh clauses', () => { + const weaponDamage = { + id: 'pummel-weapon-damage', + label: 'Weapon Damage', + phase: 'while_active' as const, + kind: 'attribute_modifier' as const, + scope: 'equipped_weapon' as const, + basis: 'flat' as const, + value: 10, + attributeId: 'weapon-damage', + statePool: 'pummel-active', + }; + const program: MechanicProgram = { + id: 'pummel-refresh', + name: 'Pummel Refresh', + pools: [ + { + key: 'pummel-active', + label: 'Pummel active', + scope: 'agent', + initial: 0, + min: 0, + max: 1, + expiresAfterSeconds: 12, + }, + ], + clauses: [ + { + id: 'activate-on-kill', + when: 'enemy_killed', + reducer: { kind: 'none' }, + mutations: [{ kind: 'set', pool: 'pummel-active', value: 1 }], + outcomes: [weaponDamage], + }, + { + id: 'refresh-on-assist', + when: 'assist', + reducer: { kind: 'none' }, + mutations: [{ kind: 'set', pool: 'pummel-active', value: 1 }], + outcomes: [{ ...weaponDamage }], + }, + ], + }; - expect(validateMechanicProgram(program)).toMatchObject({ ok: true }); - const result = evaluateMechanicProgram(program, [ - { kind: 'enemy_killed', at: 1 }, - { kind: 'assist', at: 10 } - ], custom(20)); + expect(validateMechanicProgram(program)).toMatchObject({ ok: true }); + const result = evaluateMechanicProgram( + program, + [ + { kind: 'enemy_killed', at: 1 }, + { kind: 'assist', at: 10 }, + ], + custom(20), + ); - expect(result.status).toBe('OK'); - expect(result.outcomes.map(({ outcomeId, at, appliedValue }) => ({ outcomeId, at, appliedValue }))) - .toEqual([ - { outcomeId: 'pummel-weapon-damage', at: 1, appliedValue: 10 }, - { outcomeId: 'pummel-weapon-damage', at: 10, appliedValue: 10 } - ]); - expect(result.finalState).toContainEqual(expect.objectContaining({ - pool: 'pummel-active', value: 1, expiresAt: 22 - })); - }); + expect(result.status).toBe('OK'); + expect( + result.outcomes.map(({ outcomeId, at, appliedValue }) => ({ + outcomeId, + at, + appliedValue, + })), + ).toEqual([ + { outcomeId: 'pummel-weapon-damage', at: 1, appliedValue: 10 }, + { outcomeId: 'pummel-weapon-damage', at: 10, appliedValue: 10 }, + ]); + expect(result.finalState).toContainEqual( + expect.objectContaining({ + pool: 'pummel-active', + value: 1, + expiresAt: 22, + }), + ); + }); - it('rejects a shared outcome ID when its mechanical definitions conflict', () => { - const outcome = { - id: 'shared-damage', - label: 'Weapon Damage', - phase: 'on_event' as const, - kind: 'attribute_modifier' as const, - scope: 'equipped_weapon' as const, - basis: 'flat' as const, - value: 10, - attributeId: 'weapon-damage' - }; - const program: MechanicProgram = { - id: 'conflicting-shared-outcome', - name: 'Conflicting Shared Outcome', - clauses: [ - { - id: 'kill', when: 'enemy_killed', reducer: { kind: 'none' }, - mutations: [{ kind: 'none' }], outcomes: [outcome] - }, - { - id: 'assist', when: 'assist', reducer: { kind: 'none' }, - mutations: [{ kind: 'none' }], outcomes: [{ ...outcome, value: 14 }] - } - ] - }; + it('rejects a shared outcome ID when its mechanical definitions conflict', () => { + const outcome = { + id: 'shared-damage', + label: 'Weapon Damage', + phase: 'on_event' as const, + kind: 'attribute_modifier' as const, + scope: 'equipped_weapon' as const, + basis: 'flat' as const, + value: 10, + attributeId: 'weapon-damage', + }; + const program: MechanicProgram = { + id: 'conflicting-shared-outcome', + name: 'Conflicting Shared Outcome', + clauses: [ + { + id: 'kill', + when: 'enemy_killed', + reducer: { kind: 'none' }, + mutations: [{ kind: 'none' }], + outcomes: [outcome], + }, + { + id: 'assist', + when: 'assist', + reducer: { kind: 'none' }, + mutations: [{ kind: 'none' }], + outcomes: [{ ...outcome, value: 14 }], + }, + ], + }; - expect(validateMechanicProgram(program)).toEqual({ - ok: false, - errors: ['Outcome "shared-damage" has conflicting definitions.'] - }); - }); + expect(validateMechanicProgram(program)).toEqual({ + ok: false, + errors: ['Outcome "shared-damage" has conflicting definitions.'], + }); + }); - it('applies distance bonuses in full five-meter steps and caps the result', () => { - const distanceBonus = ( - base: number, - increment: number, - maximum: number - ): NumericExpression => ({ - op: 'min', - left: maximum, - right: { - op: 'add', - left: base, - right: { - op: 'multiply', - left: increment, - right: { - op: 'floor', - value: { - op: 'divide', - left: { - op: 'max', - left: { ref: { source: 'event', path: 'values.distanceMeters' } }, - right: 0 - }, - right: 5 - } - } - } - } - }); - const program: MechanicProgram = { - id: 'long-distance-marking', - name: 'Long-Distance Marking', - clauses: [{ - id: 'headshot', - when: 'weapon_hit', - reducer: { kind: 'none' }, - mutations: [], - outcomes: [{ - id: 'skill-damage', - label: 'Skill Damage', - phase: 'after_event', - kind: 'attribute_modifier', - scope: 'self', - attributeId: 'skill-damage', - basis: 'flat', - value: distanceBonus(7.5, 1.5, 18) - }] - }] - }; + it('applies distance bonuses in full five-meter steps and caps the result', () => { + const distanceBonus = ( + base: number, + increment: number, + maximum: number, + ): NumericExpression => ({ + op: 'min', + left: maximum, + right: { + op: 'add', + left: base, + right: { + op: 'multiply', + left: increment, + right: { + op: 'floor', + value: { + op: 'divide', + left: { + op: 'max', + left: { + ref: { source: 'event', path: 'values.distanceMeters' }, + }, + right: 0, + }, + right: 5, + }, + }, + }, + }, + }); + const program: MechanicProgram = { + id: 'long-distance-marking', + name: 'Long-Distance Marking', + clauses: [ + { + id: 'headshot', + when: 'weapon_hit', + reducer: { kind: 'none' }, + mutations: [], + outcomes: [ + { + id: 'skill-damage', + label: 'Skill Damage', + phase: 'after_event', + kind: 'attribute_modifier', + scope: 'self', + attributeId: 'skill-damage', + basis: 'flat', + value: distanceBonus(7.5, 1.5, 18), + }, + ], + }, + ], + }; - expect(validateMechanicProgram(program)).toMatchObject({ ok: true }); - const result = evaluateMechanicProgram( - program, - [-1, 0, 4.99, 5, 9.99, 10, 34.99, 35, 100].map((distanceMeters, at) => ({ - kind: 'weapon_hit' as const, - at, - values: { distanceMeters } - })), - custom(10) - ); + expect(validateMechanicProgram(program)).toMatchObject({ ok: true }); + const result = evaluateMechanicProgram( + program, + [-1, 0, 4.99, 5, 9.99, 10, 34.99, 35, 100].map((distanceMeters, at) => ({ + kind: 'weapon_hit' as const, + at, + values: { distanceMeters }, + })), + custom(10), + ); - expect(result.status).toBe('OK'); - expect(result.outcomes.map((outcome) => outcome.resolvedValue)).toEqual([ - 7.5, 7.5, 7.5, 9, 9, 10.5, 16.5, 18, 18 - ]); - expect(result.script).toContain( - 'MIN(18, (7.5 + (1.5 * FLOOR((MAX(EVENT.values.distanceMeters, 0) / 5)))))' - ); - }); + expect(result.status).toBe('OK'); + expect(result.outcomes.map((outcome) => outcome.resolvedValue)).toEqual([ + 7.5, 7.5, 7.5, 9, 9, 10.5, 16.5, 18, 18, + ]); + expect(result.script).toContain( + 'MIN(18, (7.5 + (1.5 * FLOOR((MAX(EVENT.values.distanceMeters, 0) / 5)))))', + ); + }); - it('supports an immediate equipped-weapon magazine refill', () => { - const program: MechanicProgram = { - id: 'equipped-magazine', - name: 'Equipped Magazine', - clauses: [{ - id: 'refill-equipped-weapon', - when: 'enemy_killed', - reducer: { kind: 'none' }, - mutations: [{ kind: 'none' }], - outcomes: [{ - id: 'equipped-weapon-reload', - label: 'Refill equipped weapon', - phase: 'after_event', - kind: 'weapon_reload', - scope: 'equipped_weapon', - basis: 'flat', - value: 0 - }] - }] - }; + it('supports an immediate equipped-weapon magazine refill', () => { + const program: MechanicProgram = { + id: 'equipped-magazine', + name: 'Equipped Magazine', + clauses: [ + { + id: 'refill-equipped-weapon', + when: 'enemy_killed', + reducer: { kind: 'none' }, + mutations: [{ kind: 'none' }], + outcomes: [ + { + id: 'equipped-weapon-reload', + label: 'Refill equipped weapon', + phase: 'after_event', + kind: 'weapon_reload', + scope: 'equipped_weapon', + basis: 'flat', + value: 0, + }, + ], + }, + ], + }; - const result = evaluateMechanicProgram( - program, - [{ kind: 'enemy_killed', at: 1 }], - custom(10) - ); + const result = evaluateMechanicProgram( + program, + [{ kind: 'enemy_killed', at: 1 }], + custom(10), + ); - expect(result.status).toBe('OK'); - expect(result.outcomes).toEqual([ - expect.objectContaining({ - kind: 'weapon_reload', - scope: 'equipped_weapon', - at: 1, - value: 0 - }) - ]); - expect(result.script).toContain('RELOAD EQUIPPED WEAPON AFTER 0s'); - }); + expect(result.status).toBe('OK'); + expect(result.outcomes).toEqual([ + expect.objectContaining({ + kind: 'weapon_reload', + scope: 'equipped_weapon', + at: 1, + value: 0, + }), + ]); + expect(result.script).toContain('RELOAD EQUIPPED WEAPON AFTER 0s'); + }); - it('schedules an inactive-weapon reload and refreshes a pending delay', () => { - const program: MechanicProgram = { - id: 'backup-magazine', - name: 'Backup Magazine', - clauses: [{ - id: 'reload-inactive-weapon', - when: 'weapon_swapped', - reducer: { kind: 'none' }, - mutations: [{ kind: 'none' }], - outcomes: [{ - id: 'inactive-weapon-reload', - label: 'Reload inactive weapon', - phase: 'after_event', - kind: 'weapon_reload', - scope: 'inactive_weapon', - basis: 'flat', - value: 4 - }] - }] - }; + it('schedules an inactive-weapon reload and refreshes a pending delay', () => { + const program: MechanicProgram = { + id: 'backup-magazine', + name: 'Backup Magazine', + clauses: [ + { + id: 'reload-inactive-weapon', + when: 'weapon_swapped', + reducer: { kind: 'none' }, + mutations: [{ kind: 'none' }], + outcomes: [ + { + id: 'inactive-weapon-reload', + label: 'Reload inactive weapon', + phase: 'after_event', + kind: 'weapon_reload', + scope: 'inactive_weapon', + basis: 'flat', + value: 4, + }, + ], + }, + ], + }; - const refreshed = evaluateMechanicProgram(program, [ - { kind: 'weapon_swapped', at: 1 }, - { kind: 'weapon_swapped', at: 3 } - ], custom(10)); - expect(refreshed.status).toBe('OK'); - expect(refreshed.emissions.map(({ at }) => at)).toEqual([1, 3]); - expect(refreshed.outcomes).toEqual([ - expect.objectContaining({ - kind: 'weapon_reload', - scope: 'inactive_weapon', - at: 7, - value: 4 - }) - ]); - expect(refreshed.script).toContain('RELOAD INACTIVE WEAPON AFTER 4s'); + const refreshed = evaluateMechanicProgram( + program, + [ + { kind: 'weapon_swapped', at: 1 }, + { kind: 'weapon_swapped', at: 3 }, + ], + custom(10), + ); + expect(refreshed.status).toBe('OK'); + expect(refreshed.emissions.map(({ at }) => at)).toEqual([1, 3]); + expect(refreshed.outcomes).toEqual([ + expect.objectContaining({ + kind: 'weapon_reload', + scope: 'inactive_weapon', + at: 7, + value: 4, + }), + ]); + expect(refreshed.script).toContain('RELOAD INACTIVE WEAPON AFTER 4s'); - const beyondEncounter = evaluateMechanicProgram( - program, - [{ kind: 'weapon_swapped', at: 8 }], - custom(10) - ); - expect(beyondEncounter.outcomes).toEqual([]); - }); + const beyondEncounter = evaluateMechanicProgram( + program, + [{ kind: 'weapon_swapped', at: 8 }], + custom(10), + ); + expect(beyondEncounter.outcomes).toEqual([]); + }); - it('enforces clause cooldowns without counting events received during the cooldown', () => { - const program: MechanicProgram = { - id: 'cooldown', - name: 'Cooldown', - clauses: [{ - id: 'shot-proc', - when: 'shot_fired', - reducer: { kind: 'none' }, - cooldownSeconds: 2, - mutations: [{ kind: 'none' }], - outcomes: [{ - id: 'proc', - label: 'Proc', - phase: 'after_event', - kind: 'marker', - scope: 'self', - basis: 'flat', - value: 1 - }] - }] - }; - const result = evaluateMechanicProgram(program, [ - { kind: 'shot_fired', at: 0 }, - { kind: 'shot_fired', at: 1 }, - { kind: 'shot_fired', at: 2 }, - { kind: 'shot_fired', at: 3 } - ], custom(4)); - expect(result.emissions.map(({ at }) => at)).toEqual([0, 2]); - expect(result.outcomes.map(({ at }) => at)).toEqual([0, 2]); - expect(result.script).toContain('COOLDOWN 2s'); - }); + it('enforces clause cooldowns without counting events received during the cooldown', () => { + const program: MechanicProgram = { + id: 'cooldown', + name: 'Cooldown', + clauses: [ + { + id: 'shot-proc', + when: 'shot_fired', + reducer: { kind: 'none' }, + cooldownSeconds: 2, + mutations: [{ kind: 'none' }], + outcomes: [ + { + id: 'proc', + label: 'Proc', + phase: 'after_event', + kind: 'marker', + scope: 'self', + basis: 'flat', + value: 1, + }, + ], + }, + ], + }; + const result = evaluateMechanicProgram( + program, + [ + { kind: 'shot_fired', at: 0 }, + { kind: 'shot_fired', at: 1 }, + { kind: 'shot_fired', at: 2 }, + { kind: 'shot_fired', at: 3 }, + ], + custom(4), + ); + expect(result.emissions.map(({ at }) => at)).toEqual([0, 2]); + expect(result.outcomes.map(({ at }) => at)).toEqual([0, 2]); + expect(result.script).toContain('COOLDOWN 2s'); + }); - it('rejects invalid references, reducers, and target transfer scopes', () => { - const invalid: MechanicProgram = { - id: 'invalid', - name: 'Invalid', - pools: [{ key: 'agent-state', label: 'Agent State', scope: 'agent', initial: 0 }], - clauses: [{ - id: 'bad', - recipeKey: '', - when: 'shot_fired', - reducer: { kind: 'modulo', every: 0 }, - mutations: [{ - kind: 'transfer', - pool: 'agent-state', - from: { kind: 'agent' }, - to: { kind: 'event_target' } - }], - outcomes: [{ - id: 'missing-attribute', - label: 'Missing', - phase: 'after_event', - kind: 'attribute_modifier', - basis: 'flat', - scope: 'self', - value: { ref: { source: 'scenario', key: 'missing-input' } } - }, { - id: 'missing-resource', - label: 'Missing resource', - phase: 'after_event', - kind: 'restoration', - scope: 'self', - basis: 'flat', - value: 1 - }] - }] - }; - const result = validateMechanicProgram(invalid); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.errors).toContain('Clause 1 modulo interval must be a positive integer.'); - expect(result.errors).toContain('Clause 1 recipe key cannot be empty.'); - expect(result.errors).toContain('Clause 1 mutation 1 can only transfer a target-scoped pool.'); - expect(result.errors).toContain('Clause 1 outcome 1 requires an Attribute ID.'); - expect(result.errors).toContain('Clause 1 outcome 1 value references unknown scenario input "missing-input".'); - expect(result.errors).toContain('Clause 1 outcome 2 requires a resource.'); - } - }); + it('rejects invalid references, reducers, and target transfer scopes', () => { + const invalid: MechanicProgram = { + id: 'invalid', + name: 'Invalid', + pools: [ + { + key: 'agent-state', + label: 'Agent State', + scope: 'agent', + initial: 0, + }, + ], + clauses: [ + { + id: 'bad', + recipeKey: '', + when: 'shot_fired', + reducer: { kind: 'modulo', every: 0 }, + mutations: [ + { + kind: 'transfer', + pool: 'agent-state', + from: { kind: 'agent' }, + to: { kind: 'event_target' }, + }, + ], + outcomes: [ + { + id: 'missing-attribute', + label: 'Missing', + phase: 'after_event', + kind: 'attribute_modifier', + basis: 'flat', + scope: 'self', + value: { ref: { source: 'scenario', key: 'missing-input' } }, + }, + { + id: 'missing-resource', + label: 'Missing resource', + phase: 'after_event', + kind: 'restoration', + scope: 'self', + basis: 'flat', + value: 1, + }, + ], + }, + ], + }; + const result = validateMechanicProgram(invalid); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors).toContain( + 'Clause 1 modulo interval must be a positive integer.', + ); + expect(result.errors).toContain('Clause 1 recipe key cannot be empty.'); + expect(result.errors).toContain( + 'Clause 1 mutation 1 can only transfer a target-scoped pool.', + ); + expect(result.errors).toContain( + 'Clause 1 outcome 1 requires an Attribute ID.', + ); + expect(result.errors).toContain( + 'Clause 1 outcome 1 value references unknown scenario input "missing-input".', + ); + expect(result.errors).toContain( + 'Clause 1 outcome 2 requires a resource.', + ); + } + }); - it('rejects invalid continuous-delivery combinations', () => { - const base = { - id: 'delivery-validation', - name: 'Delivery Validation', - clauses: [{ - id: 'invalid-delivery', - when: 'enemy_killed' as const, - reducer: { kind: 'none' as const }, - mutations: [], - outcomes: [{ - id: 'damage-over-time', - label: 'Damage', - phase: 'after_event' as const, - kind: 'direct_damage' as const, - scope: 'target' as const, - basis: 'flat' as const, - value: 10, - delivery: 'continuous' as const, - durationSeconds: 3 - }] - }] - }; - const result = validateMechanicProgram(base); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.errors).toContain( - 'Clause 1 outcome 1 can only use continuous delivery for restoration.' - ); - } - }); + it('rejects invalid continuous-delivery combinations', () => { + const base = { + id: 'delivery-validation', + name: 'Delivery Validation', + clauses: [ + { + id: 'invalid-delivery', + when: 'enemy_killed' as const, + reducer: { kind: 'none' as const }, + mutations: [], + outcomes: [ + { + id: 'damage-over-time', + label: 'Damage', + phase: 'after_event' as const, + kind: 'direct_damage' as const, + scope: 'target' as const, + basis: 'flat' as const, + value: 10, + delivery: 'continuous' as const, + durationSeconds: 3, + }, + ], + }, + ], + }; + const result = validateMechanicProgram(base); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors).toContain( + 'Clause 1 outcome 1 can only use continuous delivery for restoration.', + ); + } + }); - it('caps Signature Ability Charge at 1000 and scales grants by charge efficiency', () => { - const efficiencyAttributeId = 'signature-charge-efficiency'; - const program: MechanicProgram = { - id: 'signature-charge', - name: 'Signature Charge', - clauses: [{ - id: 'grant-charge', - when: 'weapon_hit', - reducer: { kind: 'none' }, - mutations: [], - outcomes: [{ - id: 'charge', - label: 'Signature Ability Charge', - phase: 'after_event', - kind: 'restoration', - scope: 'self', - resource: 'signature_ability_charge', - basis: 'flat', - value: { - op: 'multiply', - left: 70, - right: { - op: 'add', - left: 1, - right: { - op: 'divide', - left: { ref: { source: 'attribute', attributeId: efficiencyAttributeId } }, - right: 100 - } - } - } - }] - }] - }; - const result = evaluateMechanicProgram( - program, - [ - { kind: 'weapon_hit', at: 1 }, - { kind: 'weapon_hit', at: 2 } - ], - custom(), - { - maxHealth: 100, - currentHealth: 100, - currentSignatureAbilityCharge: 900, - attributes: { [efficiencyAttributeId]: 50 } - } - ); - expect(result.status).toBe('OK'); - expect(result.outcomes.map((outcome) => outcome.appliedValue)).toEqual([100, 0]); - expect(result.finalCombat?.currentSignatureAbilityCharge).toBe(1000); - expect(result.finalCombat?.maxSignatureAbilityCharge).toBeUndefined(); - }); + it('caps Signature Ability Charge at 1000 and scales grants by charge efficiency', () => { + const efficiencyAttributeId = 'signature-charge-efficiency'; + const program: MechanicProgram = { + id: 'signature-charge', + name: 'Signature Charge', + clauses: [ + { + id: 'grant-charge', + when: 'weapon_hit', + reducer: { kind: 'none' }, + mutations: [], + outcomes: [ + { + id: 'charge', + label: 'Signature Ability Charge', + phase: 'after_event', + kind: 'restoration', + scope: 'self', + resource: 'signature_ability_charge', + basis: 'flat', + value: { + op: 'multiply', + left: 70, + right: { + op: 'add', + left: 1, + right: { + op: 'divide', + left: { + ref: { + source: 'attribute', + attributeId: efficiencyAttributeId, + }, + }, + right: 100, + }, + }, + }, + }, + ], + }, + ], + }; + const result = evaluateMechanicProgram( + program, + [ + { kind: 'weapon_hit', at: 1 }, + { kind: 'weapon_hit', at: 2 }, + ], + custom(), + { + maxHealth: 100, + currentHealth: 100, + currentSignatureAbilityCharge: 900, + attributes: { [efficiencyAttributeId]: 50 }, + }, + ); + expect(result.status).toBe('OK'); + expect(result.outcomes.map((outcome) => outcome.appliedValue)).toEqual([ + 100, 0, + ]); + expect(result.finalCombat?.currentSignatureAbilityCharge).toBe(1000); + expect(result.finalCombat?.maxSignatureAbilityCharge).toBeUndefined(); + }); - it('evaluates mutually exclusive current Health percentage bands at strict boundaries', () => { - const band = ( - id: string, - minimum: number | undefined, - maximum: number, - value: number - ): MechanicProgram['clauses'][number] => ({ - id, - when: 'tick', - if: minimum === undefined - ? { - kind: 'compare', - left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' }, - operator: 'lt', - right: maximum - } - : { - kind: 'all', - predicates: [{ - kind: 'compare', - left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' }, - operator: 'gte', - right: minimum - }, { - kind: 'compare', - left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' }, - operator: 'lt', - right: maximum - }] - }, - reducer: { kind: 'none' }, - mutations: [], - outcomes: [{ - id: `${id}-damage-reduction`, - label: 'Damage Reduction', - phase: 'while_active', - kind: 'attribute_modifier', - scope: 'self', - basis: 'flat', - attributeId: 'damage-reduction', - value - }] - }); - const spirited: MechanicProgram = { - id: 'spirited', - name: 'Spirited', - clauses: [ - band('critical', undefined, 25, 15), - band('damaged', 25, 50, 12), - band('wounded', 50, 75, 9) - ] - }; - const evaluateAt = (healthPercent: number, mode: 'custom' | 'maximum' = 'custom') => - evaluateMechanicProgram( - spirited, - [{ kind: 'tick', at: 0 }], - mode === 'maximum' - ? { mode: 'maximum', encounterSeconds: 1 } - : custom(1), - { maxHealth: 1_000, currentHealth: healthPercent * 10 } - ); - const valuesAt = (healthPercent: number, mode: 'custom' | 'maximum' = 'custom') => - evaluateAt(healthPercent, mode).outcomes.map((outcome) => outcome.appliedValue); + it('evaluates mutually exclusive current Health percentage bands at strict boundaries', () => { + const band = ( + id: string, + minimum: number | undefined, + maximum: number, + value: number, + ): MechanicProgram['clauses'][number] => ({ + id, + when: 'tick', + if: + minimum === undefined + ? { + kind: 'compare', + left: { + source: 'combat', + path: 'currentHealthPercent', + timing: 'current', + }, + operator: 'lt', + right: maximum, + } + : { + kind: 'all', + predicates: [ + { + kind: 'compare', + left: { + source: 'combat', + path: 'currentHealthPercent', + timing: 'current', + }, + operator: 'gte', + right: minimum, + }, + { + kind: 'compare', + left: { + source: 'combat', + path: 'currentHealthPercent', + timing: 'current', + }, + operator: 'lt', + right: maximum, + }, + ], + }, + reducer: { kind: 'none' }, + mutations: [], + outcomes: [ + { + id: `${id}-damage-reduction`, + label: 'Damage Reduction', + phase: 'while_active', + kind: 'attribute_modifier', + scope: 'self', + basis: 'flat', + attributeId: 'damage-reduction', + value, + }, + ], + }); + const spirited: MechanicProgram = { + id: 'spirited', + name: 'Spirited', + clauses: [ + band('critical', undefined, 25, 15), + band('damaged', 25, 50, 12), + band('wounded', 50, 75, 9), + ], + }; + const evaluateAt = ( + healthPercent: number, + mode: 'custom' | 'maximum' = 'custom', + ) => + evaluateMechanicProgram( + spirited, + [{ kind: 'tick', at: 0 }], + mode === 'maximum' + ? { mode: 'maximum', encounterSeconds: 1 } + : custom(1), + { maxHealth: 1_000, currentHealth: healthPercent * 10 }, + ); + const valuesAt = ( + healthPercent: number, + mode: 'custom' | 'maximum' = 'custom', + ) => + evaluateAt(healthPercent, mode).outcomes.map( + (outcome) => outcome.appliedValue, + ); - expect(valuesAt(0)).toEqual([15]); - expect(valuesAt(24.99)).toEqual([15]); - expect(valuesAt(25)).toEqual([12]); - expect(valuesAt(49.99)).toEqual([12]); - expect(valuesAt(50)).toEqual([9]); - expect(valuesAt(74.99)).toEqual([9]); - expect(valuesAt(75)).toEqual([]); - expect(valuesAt(100)).toEqual([]); - expect(valuesAt(24, 'maximum')).toEqual([15]); - }); + expect(valuesAt(0)).toEqual([15]); + expect(valuesAt(24.99)).toEqual([15]); + expect(valuesAt(25)).toEqual([12]); + expect(valuesAt(49.99)).toEqual([12]); + expect(valuesAt(50)).toEqual([9]); + expect(valuesAt(74.99)).toEqual([9]); + expect(valuesAt(75)).toEqual([]); + expect(valuesAt(100)).toEqual([]); + expect(valuesAt(24, 'maximum')).toEqual([15]); + }); - it('re-evaluates current Health percentage after deterministic combat-state updates', () => { - const program: MechanicProgram = { - id: 'live-health', - name: 'Live Health', - clauses: [{ - id: 'health-cost', - when: 'tick', - reducer: { kind: 'none' }, - mutations: [], - outcomes: [{ - id: 'cost', - label: 'Health cost', - phase: 'on_event', - kind: 'resource_cost', - scope: 'self', - resource: 'health', - basis: 'flat', - value: 30 - }] - }, { - id: 'below-seventy-five', - when: 'tick', - if: { - kind: 'compare', - left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' }, - operator: 'lt', - right: 75 - }, - reducer: { kind: 'none' }, - mutations: [], - outcomes: [{ - id: 'reduction', - label: 'Damage Reduction', - phase: 'while_active', - kind: 'attribute_modifier', - scope: 'self', - attributeId: 'damage-reduction', - basis: 'flat', - value: 9 - }] - }] - }; - const result = evaluateMechanicProgram( - program, - [{ kind: 'tick', at: 0 }], - custom(1), - { maxHealth: 100, currentHealth: 100 } - ); - expect(result.finalCombat?.currentHealth).toBe(70); - expect(result.outcomes.map((outcome) => outcome.outcomeId)).toEqual(['cost', 'reduction']); - }); + it('re-evaluates current Health percentage after deterministic combat-state updates', () => { + const program: MechanicProgram = { + id: 'live-health', + name: 'Live Health', + clauses: [ + { + id: 'health-cost', + when: 'tick', + reducer: { kind: 'none' }, + mutations: [], + outcomes: [ + { + id: 'cost', + label: 'Health cost', + phase: 'on_event', + kind: 'resource_cost', + scope: 'self', + resource: 'health', + basis: 'flat', + value: 30, + }, + ], + }, + { + id: 'below-seventy-five', + when: 'tick', + if: { + kind: 'compare', + left: { + source: 'combat', + path: 'currentHealthPercent', + timing: 'current', + }, + operator: 'lt', + right: 75, + }, + reducer: { kind: 'none' }, + mutations: [], + outcomes: [ + { + id: 'reduction', + label: 'Damage Reduction', + phase: 'while_active', + kind: 'attribute_modifier', + scope: 'self', + attributeId: 'damage-reduction', + basis: 'flat', + value: 9, + }, + ], + }, + ], + }; + const result = evaluateMechanicProgram( + program, + [{ kind: 'tick', at: 0 }], + custom(1), + { maxHealth: 100, currentHealth: 100 }, + ); + expect(result.finalCombat?.currentHealth).toBe(70); + expect(result.outcomes.map((outcome) => outcome.outcomeId)).toEqual([ + 'cost', + 'reduction', + ]); + }); - it('rejects pre-event timing for the derived current Health percentage', () => { - const invalid: MechanicProgram = { - id: 'invalid-health-percent', - name: 'Invalid Health Percent', - clauses: [{ - id: 'invalid', - when: 'tick', - if: { - kind: 'compare', - left: { source: 'combat', path: 'currentHealthPercent', timing: 'pre_event' }, - operator: 'lt', - right: 50 - }, - reducer: { kind: 'none' }, - mutations: [] - }] - }; - const result = validateMechanicProgram(invalid); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.errors).toEqual(expect.arrayContaining([ - expect.stringContaining('current Health percentage must use current timing') - ])); - } - }); + it('rejects pre-event timing for the derived current Health percentage', () => { + const invalid: MechanicProgram = { + id: 'invalid-health-percent', + name: 'Invalid Health Percent', + clauses: [ + { + id: 'invalid', + when: 'tick', + if: { + kind: 'compare', + left: { + source: 'combat', + path: 'currentHealthPercent', + timing: 'pre_event', + }, + operator: 'lt', + right: 50, + }, + reducer: { kind: 'none' }, + mutations: [], + }, + ], + }; + const result = validateMechanicProgram(invalid); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors).toEqual( + expect.arrayContaining([ + expect.stringContaining( + 'current Health percentage must use current timing', + ), + ]), + ); + } + }); - it('formats nested conditions and state operations deterministically', () => { - const program: MechanicProgram = { - id: 'format', - name: 'Prepared', - pools: [{ key: 'prepared', label: 'Prepared', scope: 'agent', initial: 0, max: 10 }], - clauses: [{ - id: 'gain', - label: 'Gain Prepared', - when: 'agent_hit', - if: { - kind: 'all', - predicates: [ - { - kind: 'compare', - left: { source: 'event', path: 'values.healthPercentBefore' }, - operator: 'eq', - right: 100 - }, - { kind: 'not', predicate: { kind: 'exists', value: { source: 'event', path: 'values.blocked' } } } - ] - }, - reducer: { kind: 'none' }, - mutations: [{ kind: 'add', pool: 'prepared', value: 1 }] - }] - }; - expect(formatMechanicProgram(program)).toBe([ - 'MECHANIC PREPARED', - 'STATE prepared [AGENT] 0..10', - '', - 'WHEN AGENT HIT // Gain Prepared', - 'IF (EVENT.values.healthPercentBefore == 100) AND (NOT (EXISTS EVENT.values.blocked))', - 'ADD 1 TO prepared' - ].join('\n')); - }); + it('formats nested conditions and state operations deterministically', () => { + const program: MechanicProgram = { + id: 'format', + name: 'Prepared', + pools: [ + { + key: 'prepared', + label: 'Prepared', + scope: 'agent', + initial: 0, + max: 10, + }, + ], + clauses: [ + { + id: 'gain', + label: 'Gain Prepared', + when: 'agent_hit', + if: { + kind: 'all', + predicates: [ + { + kind: 'compare', + left: { source: 'event', path: 'values.healthPercentBefore' }, + operator: 'eq', + right: 100, + }, + { + kind: 'not', + predicate: { + kind: 'exists', + value: { source: 'event', path: 'values.blocked' }, + }, + }, + ], + }, + reducer: { kind: 'none' }, + mutations: [{ kind: 'add', pool: 'prepared', value: 1 }], + }, + ], + }; + expect(formatMechanicProgram(program)).toBe( + [ + 'MECHANIC PREPARED', + 'STATE prepared [AGENT] 0..10', + '', + 'WHEN AGENT HIT // Gain Prepared', + 'IF (EVENT.values.healthPercentBefore == 100) AND (NOT (EXISTS EVENT.values.blocked))', + 'ADD 1 TO prepared', + ].join('\n'), + ); + }); - it('preserves radius targeting on ally modifiers and enemy markers', () => { - const program: MechanicProgram = { - id: 'radius-targeting', - name: 'Radius Targeting', - clauses: [{ - id: 'kill', - when: 'enemy_killed', - reducer: { kind: 'none' }, - mutations: [{ kind: 'none' }], - outcomes: [{ - id: 'healing', - label: 'Incoming Healing', - phase: 'on_event', - kind: 'attribute_modifier', - scope: 'allies', - attributeId: 'incoming-healing', - basis: 'flat', - value: 7, - targeting: { kind: 'within_radius', population: 'allies', radiusMeters: 40 } - }, { - id: 'taunt', - label: 'Taunt', - phase: 'on_event', - kind: 'marker', - scope: 'target', - basis: 'flat', - value: 1, - targeting: { kind: 'within_radius', population: 'enemies', radiusMeters: 20 } - }] - }] - }; - const result = evaluateMechanicProgram( - program, - [{ kind: 'enemy_killed', at: 1 }], - custom(5) - ); - expect(result.status).toBe('OK'); - expect(result.outcomes.map((outcome) => outcome.targeting)).toEqual([ - { kind: 'within_radius', population: 'allies', radiusMeters: 40 }, - { kind: 'within_radius', population: 'enemies', radiusMeters: 20 } - ]); - expect(result.script).toContain('TO ALLIES WITHIN 40m'); - expect(result.script).toContain('TO ENEMIES WITHIN 20m'); - }); + it('preserves radius targeting on ally modifiers and enemy markers', () => { + const program: MechanicProgram = { + id: 'radius-targeting', + name: 'Radius Targeting', + clauses: [ + { + id: 'kill', + when: 'enemy_killed', + reducer: { kind: 'none' }, + mutations: [{ kind: 'none' }], + outcomes: [ + { + id: 'healing', + label: 'Incoming Healing', + phase: 'on_event', + kind: 'attribute_modifier', + scope: 'allies', + attributeId: 'incoming-healing', + basis: 'flat', + value: 7, + targeting: { + kind: 'within_radius', + population: 'allies', + radiusMeters: 40, + }, + }, + { + id: 'taunt', + label: 'Taunt', + phase: 'on_event', + kind: 'marker', + scope: 'target', + basis: 'flat', + value: 1, + targeting: { + kind: 'within_radius', + population: 'enemies', + radiusMeters: 20, + }, + }, + ], + }, + ], + }; + const result = evaluateMechanicProgram( + program, + [{ kind: 'enemy_killed', at: 1 }], + custom(5), + ); + expect(result.status).toBe('OK'); + expect(result.outcomes.map((outcome) => outcome.targeting)).toEqual([ + { kind: 'within_radius', population: 'allies', radiusMeters: 40 }, + { kind: 'within_radius', population: 'enemies', radiusMeters: 20 }, + ]); + expect(result.script).toContain('TO ALLIES WITHIN 40m'); + expect(result.script).toContain('TO ENEMIES WITHIN 20m'); + }); }); diff --git a/src/lib/calculations/mechanic.ts b/src/lib/calculations/mechanic.ts index f620f56..43bca7c 100644 --- a/src/lib/calculations/mechanic.ts +++ b/src/lib/calculations/mechanic.ts @@ -1,583 +1,708 @@ export const MECHANIC_EVENT_KINDS = [ - 'shot_fired', - 'weapon_hit', - 'skill_deployed', - 'signature_ability_deployed', - 'skill_hit', - 'skill_multishot_triggered', - 'agent_hit', - 'health_damage_taken', - 'damage_dealt', - 'enemy_killed', - 'assist', - 'ally_downed', - 'agent_downed', - 'ally_rescued', - 'grenade_thrown', - 'medical_kit_used', - 'healing_applied', - 'extra_health_granted', - 'extra_health_changed', - 'movement_snapshot', - 'proximity_snapshot', - 'firing_started', - 'firing_stopped', - 'reload_started', - 'reload_completed', - 'weapon_swapped', - 'tick' + 'shot_fired', + 'weapon_hit', + 'skill_deployed', + 'signature_ability_deployed', + 'skill_hit', + 'skill_multishot_triggered', + 'agent_hit', + 'health_damage_taken', + 'damage_dealt', + 'enemy_killed', + 'assist', + 'ally_downed', + 'agent_downed', + 'ally_rescued', + 'grenade_thrown', + 'medical_kit_used', + 'healing_applied', + 'extra_health_granted', + 'extra_health_changed', + 'movement_snapshot', + 'proximity_snapshot', + 'firing_started', + 'firing_stopped', + 'reload_started', + 'reload_completed', + 'weapon_swapped', + 'tick', ] as const; export type MechanicEventKind = (typeof MECHANIC_EVENT_KINDS)[number]; export type MechanicEventDefinition = { - key: MechanicEventKind; - label: string; - description: string; - fields?: readonly { - path: string; - label: string; - type: 'boolean' | 'number' | 'string'; - required?: boolean; - minimum?: number; - }[]; - strictFields?: boolean; + key: MechanicEventKind; + label: string; + description: string; + fields?: readonly { + path: string; + label: string; + type: 'boolean' | 'number' | 'string'; + required?: boolean; + minimum?: number; + }[]; + strictFields?: boolean; }; -export const MECHANIC_EVENT_REGISTRY: Readonly> = { - shot_fired: { - key: 'shot_fired', - label: 'Shot fired', - description: 'A round leaves the equipped weapon; hit confirmation is not required.', - fields: [ - { - path: 'values.hit', - label: 'Shot hit', - type: 'boolean' - }, - { - path: 'values.magazineTwentyPercentBandsCrossed', - label: '20% magazine bands crossed', - type: 'number' - } - ] - }, - weapon_hit: { - key: 'weapon_hit', - label: 'Weapon hit', - description: 'A weapon attack connects with a target. The previous target and event values distinguish target switches, headshots, cover state, and distance.', - fields: [ - { path: 'targetId', label: 'Target ID', type: 'string' }, - { path: 'values.previousTargetId', label: 'Previous target ID', type: 'string' }, - { path: 'values.headshot', label: 'Headshot', type: 'boolean' }, - { path: 'values.critical', label: 'Critical hit', type: 'boolean' }, - { path: 'values.behindCover', label: 'Agent behind cover', type: 'boolean' }, - { path: 'values.distanceMeters', label: 'Target distance', type: 'number' } - ] - }, - skill_deployed: { - key: 'skill_deployed', - label: 'Skill deployed', - description: 'The Agent deploys or activates a specialization ability.' - }, - signature_ability_deployed: { - key: 'signature_ability_deployed', - label: 'Signature Ability deployed', - description: 'The Agent deploys or activates their Signature Ability.' - }, - skill_hit: { - key: 'skill_hit', - label: 'Skill hit', - description: 'A deployed skill connects with a target. Critical and distance details belong in event values.', - fields: [ - { path: 'values.critical', label: 'Critical hit', type: 'boolean' }, - { path: 'values.distanceMeters', label: 'Target distance', type: 'number' } - ] - }, - skill_multishot_triggered: { - key: 'skill_multishot_triggered', - label: 'Skill Multi-Shot triggered', - description: 'A deployed skill triggers an additional Multi-Shot.' - }, - agent_hit: { - key: 'agent_hit', - label: 'Agent hit', - description: 'An incoming attack connects, including hits absorbed entirely by Extra Health.' - }, - health_damage_taken: { - key: 'health_damage_taken', - label: 'Health damage taken', - description: 'The Agent loses Health. Damage absorbed entirely by Extra Health does not emit this event.', - fields: [ - { path: 'values.distanceMeters', label: 'Attacker distance', type: 'number' }, - { path: 'values.recentSkillDeploymentSeconds', label: 'Seconds since relevant Skill deployment', type: 'number' } - ] - }, - damage_dealt: { - key: 'damage_dealt', - label: 'Damage dealt', - description: 'The Agent deals damage; source, target, distance, and critical details belong in event values.' - }, - enemy_killed: { - key: 'enemy_killed', - label: 'Enemy killed', - description: 'An enemy dies. Weapon or skill source may be supplied when a mechanic requires the distinction.', - fields: [ - { path: 'values.headshot', label: 'Headshot killing shot', type: 'boolean' }, - { path: 'values.critical', label: 'Critical killing shot', type: 'boolean' }, - { path: 'values.source', label: 'Killing source', type: 'string' }, - { path: 'values.behindCover', label: 'Agent behind cover', type: 'boolean' }, - { path: 'values.distanceMeters', label: 'Enemy distance', type: 'number' } - ] - }, - assist: { - key: 'assist', - label: 'Assist', - description: 'The Agent receives credit for assisting with an enemy defeat.', - fields: [ - { path: 'values.behindCover', label: 'Agent behind cover', type: 'boolean' } - ] - }, - ally_downed: { - key: 'ally_downed', - label: 'Ally downed', - description: 'An allied Agent enters the downed state.', - fields: [ - { path: 'values.distanceMeters', label: 'Ally distance', type: 'number' } - ] - }, - agent_downed: { - key: 'agent_downed', - label: 'Agent downed', - description: 'The Agent enters the downed state.' - }, - ally_rescued: { - key: 'ally_rescued', - label: 'Ally rescued', - description: 'The Agent completes a rescue of a downed ally.' - }, - grenade_thrown: { - key: 'grenade_thrown', - label: 'Grenade thrown', - description: 'The Agent throws a grenade and consumes it from inventory.' - }, - medical_kit_used: { - key: 'medical_kit_used', - label: 'Medical Kit used', - description: 'The Agent consumes a Medical Kit.' - }, - healing_applied: { - key: 'healing_applied', - label: 'Healing applied', - description: 'Health is restored; source and recipient details identify giving versus receiving healing.' - }, - extra_health_granted: { - key: 'extra_health_granted', - label: 'Extra Health granted', - description: 'The Agent grants Extra Health to a recipient.' - }, - extra_health_changed: { - key: 'extra_health_changed', - label: 'Extra Health changed', - description: 'The Agent’s remaining Extra Health changes, including grants, reductions, and the transition to zero.', - strictFields: true, - fields: [ - { path: 'values.hasExtraHealth', label: 'Has Extra Health', type: 'boolean', required: true }, - { path: 'values.currentExtraHealth', label: 'Current Extra Health', type: 'number', minimum: 0 } - ] - }, - movement_snapshot: { - key: 'movement_snapshot', - label: 'Movement snapshot', - description: 'A one-second sample of the Agent movement state.', - fields: [ - { - path: 'values.state', - label: 'Movement state (immobile, moving, or sprinting)', - type: 'string' - } - ] - }, - proximity_snapshot: { - key: 'proximity_snapshot', - label: 'Proximity snapshot', - description: 'A sampled count or state for nearby allies, enemies, and Agent-owned buffs.' - }, - firing_started: { - key: 'firing_started', - label: 'Firing started', - description: 'The equipped weapon begins an uninterrupted firing segment.' - }, - firing_stopped: { - key: 'firing_stopped', - label: 'Firing stopped', - description: 'The equipped weapon ends an uninterrupted firing segment.' - }, - reload_started: { - key: 'reload_started', - label: 'Reload started', - description: 'The equipped weapon begins reloading.', - fields: [ - { path: 'values.emptyMagazine', label: 'Empty magazine', type: 'boolean' } - ] - }, - reload_completed: { - key: 'reload_completed', - label: 'Reload completed', - description: 'The equipped weapon finishes reloading.' - }, - weapon_swapped: { - key: 'weapon_swapped', - label: 'Weapon swapped', - description: 'The Agent changes the equipped active weapon.' - }, - tick: { - key: 'tick', - label: 'Simulation tick', - description: 'A deterministic timeline sample used by duration and periodic mechanics.' - } +export const MECHANIC_EVENT_REGISTRY: Readonly< + Record +> = { + shot_fired: { + key: 'shot_fired', + label: 'Shot fired', + description: + 'A round leaves the equipped weapon; hit confirmation is not required.', + fields: [ + { + path: 'values.hit', + label: 'Shot hit', + type: 'boolean', + }, + { + path: 'values.magazineTwentyPercentBandsCrossed', + label: '20% magazine bands crossed', + type: 'number', + }, + ], + }, + weapon_hit: { + key: 'weapon_hit', + label: 'Weapon hit', + description: + 'A weapon attack connects with a target. The previous target and event values distinguish target switches, headshots, cover state, and distance.', + fields: [ + { path: 'targetId', label: 'Target ID', type: 'string' }, + { + path: 'values.previousTargetId', + label: 'Previous target ID', + type: 'string', + }, + { path: 'values.headshot', label: 'Headshot', type: 'boolean' }, + { path: 'values.critical', label: 'Critical hit', type: 'boolean' }, + { + path: 'values.behindCover', + label: 'Agent behind cover', + type: 'boolean', + }, + { + path: 'values.distanceMeters', + label: 'Target distance', + type: 'number', + }, + ], + }, + skill_deployed: { + key: 'skill_deployed', + label: 'Skill deployed', + description: 'The Agent deploys or activates a specialization ability.', + }, + signature_ability_deployed: { + key: 'signature_ability_deployed', + label: 'Signature Ability deployed', + description: 'The Agent deploys or activates their Signature Ability.', + }, + skill_hit: { + key: 'skill_hit', + label: 'Skill hit', + description: + 'A deployed skill connects with a target. Critical and distance details belong in event values.', + fields: [ + { path: 'values.critical', label: 'Critical hit', type: 'boolean' }, + { + path: 'values.distanceMeters', + label: 'Target distance', + type: 'number', + }, + ], + }, + skill_multishot_triggered: { + key: 'skill_multishot_triggered', + label: 'Skill Multi-Shot triggered', + description: 'A deployed skill triggers an additional Multi-Shot.', + }, + agent_hit: { + key: 'agent_hit', + label: 'Agent hit', + description: + 'An incoming attack connects, including hits absorbed entirely by Extra Health.', + }, + health_damage_taken: { + key: 'health_damage_taken', + label: 'Health damage taken', + description: + 'The Agent loses Health. Damage absorbed entirely by Extra Health does not emit this event.', + fields: [ + { + path: 'values.distanceMeters', + label: 'Attacker distance', + type: 'number', + }, + { + path: 'values.recentSkillDeploymentSeconds', + label: 'Seconds since relevant Skill deployment', + type: 'number', + }, + ], + }, + damage_dealt: { + key: 'damage_dealt', + label: 'Damage dealt', + description: + 'The Agent deals damage; source, target, distance, and critical details belong in event values.', + }, + enemy_killed: { + key: 'enemy_killed', + label: 'Enemy killed', + description: + 'An enemy dies. Weapon or skill source may be supplied when a mechanic requires the distinction.', + fields: [ + { + path: 'values.headshot', + label: 'Headshot killing shot', + type: 'boolean', + }, + { + path: 'values.critical', + label: 'Critical killing shot', + type: 'boolean', + }, + { path: 'values.source', label: 'Killing source', type: 'string' }, + { + path: 'values.behindCover', + label: 'Agent behind cover', + type: 'boolean', + }, + { + path: 'values.distanceMeters', + label: 'Enemy distance', + type: 'number', + }, + ], + }, + assist: { + key: 'assist', + label: 'Assist', + description: + 'The Agent receives credit for assisting with an enemy defeat.', + fields: [ + { + path: 'values.behindCover', + label: 'Agent behind cover', + type: 'boolean', + }, + ], + }, + ally_downed: { + key: 'ally_downed', + label: 'Ally downed', + description: 'An allied Agent enters the downed state.', + fields: [ + { path: 'values.distanceMeters', label: 'Ally distance', type: 'number' }, + ], + }, + agent_downed: { + key: 'agent_downed', + label: 'Agent downed', + description: 'The Agent enters the downed state.', + }, + ally_rescued: { + key: 'ally_rescued', + label: 'Ally rescued', + description: 'The Agent completes a rescue of a downed ally.', + }, + grenade_thrown: { + key: 'grenade_thrown', + label: 'Grenade thrown', + description: 'The Agent throws a grenade and consumes it from inventory.', + }, + medical_kit_used: { + key: 'medical_kit_used', + label: 'Medical Kit used', + description: 'The Agent consumes a Medical Kit.', + }, + healing_applied: { + key: 'healing_applied', + label: 'Healing applied', + description: + 'Health is restored; source and recipient details identify giving versus receiving healing.', + }, + extra_health_granted: { + key: 'extra_health_granted', + label: 'Extra Health granted', + description: 'The Agent grants Extra Health to a recipient.', + }, + extra_health_changed: { + key: 'extra_health_changed', + label: 'Extra Health changed', + description: + 'The Agent’s remaining Extra Health changes, including grants, reductions, and the transition to zero.', + strictFields: true, + fields: [ + { + path: 'values.hasExtraHealth', + label: 'Has Extra Health', + type: 'boolean', + required: true, + }, + { + path: 'values.currentExtraHealth', + label: 'Current Extra Health', + type: 'number', + minimum: 0, + }, + ], + }, + movement_snapshot: { + key: 'movement_snapshot', + label: 'Movement snapshot', + description: 'A one-second sample of the Agent movement state.', + fields: [ + { + path: 'values.state', + label: 'Movement state (immobile, moving, or sprinting)', + type: 'string', + }, + ], + }, + proximity_snapshot: { + key: 'proximity_snapshot', + label: 'Proximity snapshot', + description: + 'A sampled count or state for nearby allies, enemies, and Agent-owned buffs.', + }, + firing_started: { + key: 'firing_started', + label: 'Firing started', + description: 'The equipped weapon begins an uninterrupted firing segment.', + }, + firing_stopped: { + key: 'firing_stopped', + label: 'Firing stopped', + description: 'The equipped weapon ends an uninterrupted firing segment.', + }, + reload_started: { + key: 'reload_started', + label: 'Reload started', + description: 'The equipped weapon begins reloading.', + fields: [ + { + path: 'values.emptyMagazine', + label: 'Empty magazine', + type: 'boolean', + }, + ], + }, + reload_completed: { + key: 'reload_completed', + label: 'Reload completed', + description: 'The equipped weapon finishes reloading.', + }, + weapon_swapped: { + key: 'weapon_swapped', + label: 'Weapon swapped', + description: 'The Agent changes the equipped active weapon.', + }, + tick: { + key: 'tick', + label: 'Simulation tick', + description: + 'A deterministic timeline sample used by duration and periodic mechanics.', + }, }; export type Scalar = string | number | boolean | null; export type StateScope = 'agent' | 'target'; export type CalculationMode = 'maximum' | 'custom'; -export type OutcomePhase = 'on_event' | 'after_event' | 'on_enter' | 'on_exit' | 'while_active'; +export type OutcomePhase = + 'on_event' | 'after_event' | 'on_enter' | 'on_exit' | 'while_active'; export type MechanicEvent = { - kind: MechanicEventKind; - at: number; - actorId?: string | null; - targetId?: string | null; - values?: Record; + kind: MechanicEventKind; + at: number; + actorId?: string | null; + targetId?: string | null; + values?: Record; }; export type ScenarioInputDefinition = { - key: string; - label: string; - kind: 'toggle' | 'slider' | 'number'; - readiness?: 'ready' | 'requires_assumption'; - description?: string; - defaultValue?: number | boolean; - bestCaseValue?: number | boolean; - min?: number; - max?: number; + key: string; + label: string; + kind: 'toggle' | 'slider' | 'number'; + readiness?: 'ready' | 'requires_assumption'; + description?: string; + defaultValue?: number | boolean; + bestCaseValue?: number | boolean; + min?: number; + max?: number; }; export type MechanicScenario = - | { mode: 'maximum'; encounterSeconds: number } - | { mode: 'custom'; encounterSeconds: number; inputs?: Record }; + | { mode: 'maximum'; encounterSeconds: number } + | { + mode: 'custom'; + encounterSeconds: number; + inputs?: Record; + }; export type EntitySelector = - | { kind: 'agent' } - | { kind: 'event_actor' } - | { kind: 'event_target' } - | { kind: 'event_field'; path: string }; + | { kind: 'agent' } + | { kind: 'event_actor' } + | { kind: 'event_target' } + | { kind: 'event_field'; path: string }; export type ValueReference = - | { source: 'event'; path: string } - | { source: 'scenario'; key: string } - | { source: 'combat'; path: string; timing?: 'pre_event' | 'current' } - | { source: 'attribute'; attributeId: string } - | { source: 'equipment'; path: string } - | { source: 'reducer' } - | { source: 'pool'; pool: string; entity?: EntitySelector }; + | { source: 'event'; path: string } + | { source: 'scenario'; key: string } + | { source: 'combat'; path: string; timing?: 'pre_event' | 'current' } + | { source: 'attribute'; attributeId: string } + | { source: 'equipment'; path: string } + | { source: 'reducer' } + | { source: 'pool'; pool: string; entity?: EntitySelector }; export const MECHANIC_COMBAT_REFERENCE_REGISTRY = { - currentHealth: { - label: 'Current Health', - description: 'The Agent’s resolved current Health.' - }, - currentHealthPercent: { - label: 'Current Health percentage', - description: 'The Agent’s resolved current Health divided by resolved maximum Health, from 0 through 100.' - } + currentHealth: { + label: 'Current Health', + description: 'The Agent’s resolved current Health.', + }, + currentHealthPercent: { + label: 'Current Health percentage', + description: + 'The Agent’s resolved current Health divided by resolved maximum Health, from 0 through 100.', + }, } as const; export type NumericExpression = - | number - | { ref: ValueReference } - | { op: 'floor'; value: NumericExpression } - | { - op: 'add' | 'subtract' | 'multiply' | 'divide' | 'min' | 'max'; - left: NumericExpression; - right: NumericExpression; - } - | { op: 'clamp'; value: NumericExpression; min: NumericExpression; max: NumericExpression }; + | number + | { ref: ValueReference } + | { op: 'floor'; value: NumericExpression } + | { + op: 'add' | 'subtract' | 'multiply' | 'divide' | 'min' | 'max'; + left: NumericExpression; + right: NumericExpression; + } + | { + op: 'clamp'; + value: NumericExpression; + min: NumericExpression; + max: NumericExpression; + }; export type Predicate = - | { kind: 'true' } - | { kind: 'exists'; value: ValueReference } - | { - kind: 'compare'; - left: ValueReference | Scalar; - operator: 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte'; - right: ValueReference | Scalar; - } - | { kind: 'all'; predicates: Predicate[] } - | { kind: 'any'; predicates: Predicate[] } - | { kind: 'not'; predicate: Predicate }; + | { kind: 'true' } + | { kind: 'exists'; value: ValueReference } + | { + kind: 'compare'; + left: ValueReference | Scalar; + operator: 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte'; + right: ValueReference | Scalar; + } + | { kind: 'all'; predicates: Predicate[] } + | { kind: 'any'; predicates: Predicate[] } + | { kind: 'not'; predicate: Predicate }; export type Reducer = - | { kind: 'none' } - | { kind: 'count'; threshold: number; reset?: 'zero' | 'subtract' | 'never'; groupBy?: ValueReference } - | { kind: 'modulo'; every: number; groupBy?: ValueReference } - | { kind: 'distinct_count'; threshold: number; windowSeconds: number; distinctBy: ValueReference } - | { kind: 'consecutive_count'; threshold: number; groupBy?: ValueReference } - | { - kind: 'sliding_count'; - threshold: number; - windowSeconds: number; - reset?: 'zero' | 'subtract' | 'never'; - groupBy?: ValueReference; - } - | { kind: 'duration'; seconds: number; repeat?: boolean; groupBy?: ValueReference } - | { kind: 'periodic'; intervalSeconds: NumericExpression; immediate?: boolean; groupBy?: ValueReference } - | { kind: 'derived_scalar'; value: NumericExpression }; + | { kind: 'none' } + | { + kind: 'count'; + threshold: number; + reset?: 'zero' | 'subtract' | 'never'; + groupBy?: ValueReference; + } + | { kind: 'modulo'; every: number; groupBy?: ValueReference } + | { + kind: 'distinct_count'; + threshold: number; + windowSeconds: number; + distinctBy: ValueReference; + } + | { kind: 'consecutive_count'; threshold: number; groupBy?: ValueReference } + | { + kind: 'sliding_count'; + threshold: number; + windowSeconds: number; + reset?: 'zero' | 'subtract' | 'never'; + groupBy?: ValueReference; + } + | { + kind: 'duration'; + seconds: number; + repeat?: boolean; + groupBy?: ValueReference; + } + | { + kind: 'periodic'; + intervalSeconds: NumericExpression; + immediate?: boolean; + groupBy?: ValueReference; + } + | { kind: 'derived_scalar'; value: NumericExpression }; export type PoolDefinition = { - key: string; - label: string; - scope: StateScope; - initial: number; - min?: number; - max?: number; - bestCaseValue?: number; - expiresAfterSeconds?: number; + key: string; + label: string; + scope: StateScope; + initial: number; + min?: number; + max?: number; + bestCaseValue?: number; + expiresAfterSeconds?: number; }; type PoolMutationBase = { - pool: string; - entity?: EntitySelector; + pool: string; + entity?: EntitySelector; }; export type ClauseMutation = - | { kind: 'none' } - | (PoolMutationBase & { kind: 'add' | 'set' | 'consume' | 'derive'; value: NumericExpression }) - | (PoolMutationBase & { kind: 'clear' }) - | { - kind: 'transfer'; - pool: string; - from: EntitySelector; - to: EntitySelector; - value?: NumericExpression | 'all'; - clearSource?: boolean; - }; + | { kind: 'none' } + | (PoolMutationBase & { + kind: 'add' | 'set' | 'consume' | 'derive'; + value: NumericExpression; + }) + | (PoolMutationBase & { kind: 'clear' }) + | { + kind: 'transfer'; + pool: string; + from: EntitySelector; + to: EntitySelector; + value?: NumericExpression | 'all'; + clearSource?: boolean; + }; export type OutcomeBasis = - | 'flat' - | 'max_health_percent' - | 'current_health_percent' - | 'weapon_base_percent'; + | 'flat' + | 'max_health_percent' + | 'current_health_percent' + | 'weapon_base_percent'; export type MechanicOutcomeScope = - | 'self' - | 'equipped_weapon' - | 'inactive_weapon' - | 'target' - | 'allies' - | 'self_and_allies' - | 'assigned_ability' - | 'all_skills'; + | 'self' + | 'equipped_weapon' + | 'inactive_weapon' + | 'target' + | 'allies' + | 'self_and_allies' + | 'assigned_ability' + | 'all_skills'; -export type MechanicResource = 'health' | 'armor' | 'ammo' | 'signature_ability_charge'; +export type MechanicResource = + 'health' | 'armor' | 'ammo' | 'signature_ability_charge'; export type MechanicOutcomeTargeting = { - kind: 'within_radius'; - population: 'allies' | 'enemies'; - radiusMeters: number; + kind: 'within_radius'; + population: 'allies' | 'enemies'; + radiusMeters: number; }; export type MechanicOutcome = { - id: string; - label: string; - phase: OutcomePhase; - kind: 'attribute_modifier' | 'direct_damage' | 'restoration' | 'resource_cost' | 'weapon_reload' | 'marker'; - scope: MechanicOutcomeScope; - value: NumericExpression; - basis: OutcomeBasis; - resource?: MechanicResource; - delivery?: 'instant' | 'continuous'; - durationSeconds?: number; - combatTiming?: 'pre_event' | 'current'; - attributeId?: string; - statePool?: string; - perStack?: boolean; - minStacks?: number; - maxStacks?: number; - targeting?: MechanicOutcomeTargeting; + id: string; + label: string; + phase: OutcomePhase; + kind: + | 'attribute_modifier' + | 'direct_damage' + | 'restoration' + | 'resource_cost' + | 'weapon_reload' + | 'marker'; + scope: MechanicOutcomeScope; + value: NumericExpression; + basis: OutcomeBasis; + resource?: MechanicResource; + delivery?: 'instant' | 'continuous'; + durationSeconds?: number; + combatTiming?: 'pre_event' | 'current'; + attributeId?: string; + statePool?: string; + perStack?: boolean; + minStacks?: number; + maxStacks?: number; + targeting?: MechanicOutcomeTargeting; }; export type MechanicClause = { - id: string; - label?: string; - recipeKey?: string; - when: MechanicEventKind; - if?: Predicate; - reducer: Reducer; - cooldownSeconds?: number; - weight?: NumericExpression; - mutations: ClauseMutation[]; - outcomes?: MechanicOutcome[]; + id: string; + label?: string; + recipeKey?: string; + when: MechanicEventKind; + if?: Predicate; + reducer: Reducer; + cooldownSeconds?: number; + weight?: NumericExpression; + mutations: ClauseMutation[]; + outcomes?: MechanicOutcome[]; }; export type MechanicProgram = { - id: string; - name: string; - scenarioInputs?: ScenarioInputDefinition[]; - pools?: PoolDefinition[]; - clauses: MechanicClause[]; + id: string; + name: string; + scenarioInputs?: ScenarioInputDefinition[]; + pools?: PoolDefinition[]; + clauses: MechanicClause[]; }; function mechanicDefinitionsEqual(left: unknown, right: unknown): boolean { - if (Object.is(left, right)) return true; - if (typeof left !== typeof right || left === null || right === null) return false; - if (Array.isArray(left) || Array.isArray(right)) { - if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false; - return left.every((value, index) => mechanicDefinitionsEqual(value, right[index])); - } - if (typeof left !== 'object') return false; - const leftRecord = left as Record; - const rightRecord = right as Record; - const leftKeys = Object.keys(leftRecord).sort(); - const rightKeys = Object.keys(rightRecord).sort(); - return leftKeys.length === rightKeys.length - && leftKeys.every((key, index) => - key === rightKeys[index] && mechanicDefinitionsEqual(leftRecord[key], rightRecord[key])); + if (Object.is(left, right)) return true; + if (typeof left !== typeof right || left === null || right === null) + return false; + if (Array.isArray(left) || Array.isArray(right)) { + if ( + !Array.isArray(left) || + !Array.isArray(right) || + left.length !== right.length + ) + return false; + return left.every((value, index) => + mechanicDefinitionsEqual(value, right[index]), + ); + } + if (typeof left !== 'object') return false; + const leftRecord = left as Record; + const rightRecord = right as Record; + const leftKeys = Object.keys(leftRecord).sort(); + const rightKeys = Object.keys(rightRecord).sort(); + return ( + leftKeys.length === rightKeys.length && + leftKeys.every( + (key, index) => + key === rightKeys[index] && + mechanicDefinitionsEqual(leftRecord[key], rightRecord[key]), + ) + ); } export type PoolSnapshot = { - pool: string; - scope: StateScope; - entityId: string; - value: number; - expiresAt: number | null; + pool: string; + scope: StateScope; + entityId: string; + value: number; + expiresAt: number | null; }; export type MutationRecord = { - kind: ClauseMutation['kind']; - pool?: string; - entityId?: string; - before?: number; - after?: number; - targetEntityId?: string; - targetBefore?: number; - targetAfter?: number; + kind: ClauseMutation['kind']; + pool?: string; + entityId?: string; + before?: number; + after?: number; + targetEntityId?: string; + targetBefore?: number; + targetAfter?: number; }; export type MechanicEmission = { - clauseId: string; - at: number; - weight: number; - reducerValue: number; - mutations: MutationRecord[]; + clauseId: string; + at: number; + weight: number; + reducerValue: number; + mutations: MutationRecord[]; }; export type MechanicOutcomeEmission = { - clauseId: string; - outcomeId: string; - at: number; - targetId: string | null; - phase: OutcomePhase; - kind: MechanicOutcome['kind']; - scope: MechanicOutcome['scope']; - resource: MechanicResource | null; - attributeId: string | null; - basis: OutcomeBasis; - combatTiming: 'pre_event' | 'current'; - value: number; - resolvedValue: number; - appliedValue: number; - weight: number; - stateValue: number; - combatBefore: MechanicCombatContext | null; - combatAfter: MechanicCombatContext | null; - delivery: 'instant' | 'continuous'; - deliveryStart: number | null; - deliveryEnd: number | null; - totalResolvedValue: number; - targeting: MechanicOutcomeTargeting | null; + clauseId: string; + outcomeId: string; + at: number; + targetId: string | null; + phase: OutcomePhase; + kind: MechanicOutcome['kind']; + scope: MechanicOutcome['scope']; + resource: MechanicResource | null; + attributeId: string | null; + basis: OutcomeBasis; + combatTiming: 'pre_event' | 'current'; + value: number; + resolvedValue: number; + appliedValue: number; + weight: number; + stateValue: number; + combatBefore: MechanicCombatContext | null; + combatAfter: MechanicCombatContext | null; + delivery: 'instant' | 'continuous'; + deliveryStart: number | null; + deliveryEnd: number | null; + totalResolvedValue: number; + targeting: MechanicOutcomeTargeting | null; }; export type MechanicEvaluation = { - status: 'OK' | 'INVALID'; - errors: string[]; - programId: string; - mode: CalculationMode; - scenarioInputs: Record; - readiness: { - status: 'READY' | 'REQUIRES_ASSUMPTIONS'; - missingInputs: string[]; - }; - emissions: MechanicEmission[]; - outcomes: MechanicOutcomeEmission[]; - finalState: PoolSnapshot[]; - finalCombat: MechanicCombatContext | null; - equipment: MechanicEquipmentContext | null; - estimatedActivationCount: number; - script: string; + status: 'OK' | 'INVALID'; + errors: string[]; + programId: string; + mode: CalculationMode; + scenarioInputs: Record; + readiness: { + status: 'READY' | 'REQUIRES_ASSUMPTIONS'; + missingInputs: string[]; + }; + emissions: MechanicEmission[]; + outcomes: MechanicOutcomeEmission[]; + finalState: PoolSnapshot[]; + finalCombat: MechanicCombatContext | null; + equipment: MechanicEquipmentContext | null; + estimatedActivationCount: number; + script: string; }; export type MechanicCombatContext = { - maxHealth: number; - currentHealth: number; - maxArmor?: number; - currentArmor?: number; - maxAmmo?: number; - currentAmmo?: number; - maxSignatureAbilityCharge?: number; - currentSignatureAbilityCharge?: number; - weaponBaseDamage?: number; - attributes?: Record; - values?: Record; + maxHealth: number; + currentHealth: number; + maxArmor?: number; + currentArmor?: number; + maxAmmo?: number; + currentAmmo?: number; + maxSignatureAbilityCharge?: number; + currentSignatureAbilityCharge?: number; + weaponBaseDamage?: number; + attributes?: Record; + values?: Record; }; export type MechanicEquipmentContext = { - protocolTalentUpgrades?: number; - values?: Record; + protocolTalentUpgrades?: number; + values?: Record; }; export type ValidationResult = - | { ok: true; value: T } - | { ok: false; errors: string[] }; + { ok: true; value: T } | { ok: false; errors: string[] }; type RuntimePool = PoolSnapshot; type EvaluationContext = { - event: MechanicEvent; - scenario: Record; - reducerValue: number; - pools: Map; - poolDefinitions: Map; - combat: MechanicCombatContext | null; - preEventCombat: MechanicCombatContext | null; - equipment: MechanicEquipmentContext | null; + event: MechanicEvent; + scenario: Record; + reducerValue: number; + pools: Map; + poolDefinitions: Map; + combat: MechanicCombatContext | null; + preEventCombat: MechanicCombatContext | null; + equipment: MechanicEquipmentContext | null; }; type ContinuousDelivery = { - clauseId: string; - outcomeId: string; - targetId: string | null; - phase: OutcomePhase; - scope: MechanicOutcomeScope; - resource: MechanicResource; - attributeId: string | null; - basis: OutcomeBasis; - combatTiming: 'pre_event' | 'current'; - value: number; - totalResolvedValue: number; - weight: number; - stateValue: number; - start: number; - end: number; - appliedThrough: number; - targeting: MechanicOutcomeTargeting | null; + clauseId: string; + outcomeId: string; + targetId: string | null; + phase: OutcomePhase; + scope: MechanicOutcomeScope; + resource: MechanicResource; + attributeId: string | null; + basis: OutcomeBasis; + combatTiming: 'pre_event' | 'current'; + value: number; + totalResolvedValue: number; + weight: number; + stateValue: number; + start: number; + end: number; + appliedThrough: number; + targeting: MechanicOutcomeTargeting | null; }; type ReducerState = { - count: number; - timestamps: number[]; - distinctTimestamps: Map; - previousGroup: string | null; - startedAt: number | null; - lastPeriodicIndex: number; - durationEmitted: boolean; + count: number; + timestamps: number[]; + distinctTimestamps: Map; + previousGroup: string | null; + startedAt: number | null; + lastPeriodicIndex: number; + durationEmitted: boolean; }; type ReducerEmission = { at: number; value: number }; @@ -585,1735 +710,2310 @@ const AGENT_ENTITY = 'agent'; const DEFAULT_TARGET_ENTITY = 'target'; const EPSILON = 0.000_001; const finiteNumber = (value: unknown): value is number => - typeof value === 'number' && Number.isFinite(value); + typeof value === 'number' && Number.isFinite(value); const formatNumber = (value: number) => - Number.isInteger(value) ? String(value) : String(Number(value.toFixed(3))); -const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); + Number.isInteger(value) ? String(value) : String(Number(value.toFixed(3))); +const clamp = (value: number, min: number, max: number) => + Math.min(max, Math.max(min, value)); function getPath(source: unknown, path: string): unknown { - let current = source; - for (const segment of path.split('.').filter(Boolean)) { - if (typeof current !== 'object' || current === null) return undefined; - current = (current as Record)[segment]; - } - return current; + let current = source; + for (const segment of path.split('.').filter(Boolean)) { + if (typeof current !== 'object' || current === null) return undefined; + current = (current as Record)[segment]; + } + return current; } -function entityId(selector: EntitySelector | undefined, event: MechanicEvent, scope: StateScope): string | null { - if (!selector) return scope === 'agent' ? AGENT_ENTITY : event.targetId ?? null; - switch (selector.kind) { - case 'agent': - return AGENT_ENTITY; - case 'event_actor': - return event.actorId ?? AGENT_ENTITY; - case 'event_target': - return event.targetId ?? null; - case 'event_field': { - const value = getPath(event, selector.path); - return typeof value === 'string' && value.trim() ? value : null; - } - } +function entityId( + selector: EntitySelector | undefined, + event: MechanicEvent, + scope: StateScope, +): string | null { + if (!selector) + return scope === 'agent' ? AGENT_ENTITY : (event.targetId ?? null); + switch (selector.kind) { + case 'agent': + return AGENT_ENTITY; + case 'event_actor': + return event.actorId ?? AGENT_ENTITY; + case 'event_target': + return event.targetId ?? null; + case 'event_field': { + const value = getPath(event, selector.path); + return typeof value === 'string' && value.trim() ? value : null; + } + } } function poolStorageKey(pool: string, entity: string) { - return `${pool}\u0000${entity}`; + return `${pool}\u0000${entity}`; } function stateValue( - pool: string, - selector: EntitySelector | undefined, - context: EvaluationContext + pool: string, + selector: EntitySelector | undefined, + context: EvaluationContext, ): number { - const definition = context.poolDefinitions.get(pool); - if (!definition) return 0; - const entity = entityId(selector, context.event, definition.scope); - if (!entity) return definition.initial; - return context.pools.get(poolStorageKey(pool, entity))?.value ?? definition.initial; + const definition = context.poolDefinitions.get(pool); + if (!definition) return 0; + const entity = entityId(selector, context.event, definition.scope); + if (!entity) return definition.initial; + return ( + context.pools.get(poolStorageKey(pool, entity))?.value ?? definition.initial + ); } -function resolveReference(reference: ValueReference, context: EvaluationContext): unknown { - switch (reference.source) { - case 'event': - return getPath(context.event, reference.path); - case 'scenario': - return context.scenario[reference.key]; - case 'combat': { - const combat = reference.timing === 'pre_event' ? context.preEventCombat : context.combat; - if (reference.path === 'currentHealthPercent') { - if ( - !combat - || !finiteNumber(combat.maxHealth) - || combat.maxHealth <= 0 - || !finiteNumber(combat.currentHealth) - ) return undefined; - return clamp(combat.currentHealth / combat.maxHealth * 100, 0, 100); - } - return getPath(combat, reference.path); - } - case 'attribute': - return context.combat?.attributes?.[reference.attributeId]; - case 'equipment': - return getPath(context.equipment, reference.path); - case 'reducer': - return context.reducerValue; - case 'pool': - return stateValue(reference.pool, reference.entity, context); - } +function resolveReference( + reference: ValueReference, + context: EvaluationContext, +): unknown { + switch (reference.source) { + case 'event': + return getPath(context.event, reference.path); + case 'scenario': + return context.scenario[reference.key]; + case 'combat': { + const combat = + reference.timing === 'pre_event' + ? context.preEventCombat + : context.combat; + if (reference.path === 'currentHealthPercent') { + if ( + !combat || + !finiteNumber(combat.maxHealth) || + combat.maxHealth <= 0 || + !finiteNumber(combat.currentHealth) + ) + return undefined; + return clamp((combat.currentHealth / combat.maxHealth) * 100, 0, 100); + } + return getPath(combat, reference.path); + } + case 'attribute': + return context.combat?.attributes?.[reference.attributeId]; + case 'equipment': + return getPath(context.equipment, reference.path); + case 'reducer': + return context.reducerValue; + case 'pool': + return stateValue(reference.pool, reference.entity, context); + } } -function numericValue(expression: NumericExpression, context: EvaluationContext): number { - if (typeof expression === 'number') return expression; - if ('ref' in expression) { - const value = resolveReference(expression.ref, context); - return finiteNumber(value) ? value : typeof value === 'boolean' ? Number(value) : 0; - } - if (expression.op === 'floor') return Math.floor(numericValue(expression.value, context)); - if (expression.op === 'clamp') { - return clamp( - numericValue(expression.value, context), - numericValue(expression.min, context), - numericValue(expression.max, context) - ); - } - const left = numericValue(expression.left, context); - const right = numericValue(expression.right, context); - switch (expression.op) { - case 'add': - return left + right; - case 'subtract': - return left - right; - case 'multiply': - return left * right; - case 'divide': - return Math.abs(right) <= EPSILON ? 0 : left / right; - case 'min': - return Math.min(left, right); - case 'max': - return Math.max(left, right); - } +function numericValue( + expression: NumericExpression, + context: EvaluationContext, +): number { + if (typeof expression === 'number') return expression; + if ('ref' in expression) { + const value = resolveReference(expression.ref, context); + return finiteNumber(value) + ? value + : typeof value === 'boolean' + ? Number(value) + : 0; + } + if (expression.op === 'floor') + return Math.floor(numericValue(expression.value, context)); + if (expression.op === 'clamp') { + return clamp( + numericValue(expression.value, context), + numericValue(expression.min, context), + numericValue(expression.max, context), + ); + } + const left = numericValue(expression.left, context); + const right = numericValue(expression.right, context); + switch (expression.op) { + case 'add': + return left + right; + case 'subtract': + return left - right; + case 'multiply': + return left * right; + case 'divide': + return Math.abs(right) <= EPSILON ? 0 : left / right; + case 'min': + return Math.min(left, right); + case 'max': + return Math.max(left, right); + } } -function operandValue(operand: ValueReference | Scalar, context: EvaluationContext): unknown { - if (typeof operand === 'object' && operand !== null && 'source' in operand) { - return resolveReference(operand, context); - } - return operand; +function operandValue( + operand: ValueReference | Scalar, + context: EvaluationContext, +): unknown { + if (typeof operand === 'object' && operand !== null && 'source' in operand) { + return resolveReference(operand, context); + } + return operand; } -function predicateMatches(predicate: Predicate | undefined, context: EvaluationContext): boolean { - if (!predicate || predicate.kind === 'true') return true; - if (predicate.kind === 'exists') { - const value = resolveReference(predicate.value, context); - return value !== undefined && value !== null; - } - if (predicate.kind === 'all') return predicate.predicates.every((item) => predicateMatches(item, context)); - if (predicate.kind === 'any') return predicate.predicates.some((item) => predicateMatches(item, context)); - if (predicate.kind === 'not') return !predicateMatches(predicate.predicate, context); - const left = operandValue(predicate.left, context); - const right = operandValue(predicate.right, context); - switch (predicate.operator) { - case 'eq': - return left === right; - case 'ne': - return left !== right; - case 'gt': - return typeof left === 'number' && typeof right === 'number' && left > right; - case 'gte': - return typeof left === 'number' && typeof right === 'number' && left >= right; - case 'lt': - return typeof left === 'number' && typeof right === 'number' && left < right; - case 'lte': - return typeof left === 'number' && typeof right === 'number' && left <= right; - } +function predicateMatches( + predicate: Predicate | undefined, + context: EvaluationContext, +): boolean { + if (!predicate || predicate.kind === 'true') return true; + if (predicate.kind === 'exists') { + const value = resolveReference(predicate.value, context); + return value !== undefined && value !== null; + } + if (predicate.kind === 'all') + return predicate.predicates.every((item) => + predicateMatches(item, context), + ); + if (predicate.kind === 'any') + return predicate.predicates.some((item) => predicateMatches(item, context)); + if (predicate.kind === 'not') + return !predicateMatches(predicate.predicate, context); + const left = operandValue(predicate.left, context); + const right = operandValue(predicate.right, context); + switch (predicate.operator) { + case 'eq': + return left === right; + case 'ne': + return left !== right; + case 'gt': + return ( + typeof left === 'number' && typeof right === 'number' && left > right + ); + case 'gte': + return ( + typeof left === 'number' && typeof right === 'number' && left >= right + ); + case 'lt': + return ( + typeof left === 'number' && typeof right === 'number' && left < right + ); + case 'lte': + return ( + typeof left === 'number' && typeof right === 'number' && left <= right + ); + } } function scenarioValues( - program: MechanicProgram, - scenario: MechanicScenario + program: MechanicProgram, + scenario: MechanicScenario, ): { values: Record; missing: string[] } { - const supplied = scenario.mode === 'custom' ? scenario.inputs ?? {} : {}; - const values: Record = {}; - const missing: string[] = []; - for (const input of program.scenarioInputs ?? []) { - const value = scenario.mode === 'maximum' - ? input.bestCaseValue - : supplied[input.key] ?? input.defaultValue; - if (value === undefined) { - missing.push(input.key); - continue; - } - values[input.key] = value; - } - return { values, missing }; + const supplied = scenario.mode === 'custom' ? (scenario.inputs ?? {}) : {}; + const values: Record = {}; + const missing: string[] = []; + for (const input of program.scenarioInputs ?? []) { + const value = + scenario.mode === 'maximum' + ? input.bestCaseValue + : (supplied[input.key] ?? input.defaultValue); + if (value === undefined) { + missing.push(input.key); + continue; + } + values[input.key] = value; + } + return { values, missing }; } function freshReducerState(): ReducerState { - return { - count: 0, - timestamps: [], - distinctTimestamps: new Map(), - previousGroup: null, - startedAt: null, - lastPeriodicIndex: -1, - durationEmitted: false - }; + return { + count: 0, + timestamps: [], + distinctTimestamps: new Map(), + previousGroup: null, + startedAt: null, + lastPeriodicIndex: -1, + durationEmitted: false, + }; } -function reducerGroup( - reducer: Reducer, - context: EvaluationContext -): string { - if (!('groupBy' in reducer) || !reducer.groupBy) return 'global'; - const value = resolveReference(reducer.groupBy, context); - return value === undefined || value === null ? 'missing' : String(value); +function reducerGroup(reducer: Reducer, context: EvaluationContext): string { + if (!('groupBy' in reducer) || !reducer.groupBy) return 'global'; + const value = resolveReference(reducer.groupBy, context); + return value === undefined || value === null ? 'missing' : String(value); } -function resetCount(state: ReducerState, threshold: number, reset: 'zero' | 'subtract' | 'never') { - if (reset === 'zero') state.count = 0; - else if (reset === 'subtract') state.count = Math.max(0, state.count - threshold); +function resetCount( + state: ReducerState, + threshold: number, + reset: 'zero' | 'subtract' | 'never', +) { + if (reset === 'zero') state.count = 0; + else if (reset === 'subtract') + state.count = Math.max(0, state.count - threshold); } function reduceEvent( - reducer: Reducer, - matched: boolean, - event: MechanicEvent, - context: EvaluationContext, - state: ReducerState + reducer: Reducer, + matched: boolean, + event: MechanicEvent, + context: EvaluationContext, + state: ReducerState, ): ReducerEmission[] { - if (reducer.kind === 'none') return matched ? [{ at: event.at, value: 1 }] : []; - if (reducer.kind === 'derived_scalar') { - return matched ? [{ at: event.at, value: numericValue(reducer.value, context) }] : []; - } - if (reducer.kind === 'count') { - if (!matched) return []; - state.count += 1; - if ((reducer.reset ?? 'zero') === 'never' && state.durationEmitted) return []; - if (state.count < reducer.threshold) return []; - const value = state.count; - if ((reducer.reset ?? 'zero') === 'never') state.durationEmitted = true; - resetCount(state, reducer.threshold, reducer.reset ?? 'zero'); - return [{ at: event.at, value }]; - } - if (reducer.kind === 'modulo') { - if (!matched) return []; - state.count += 1; - return state.count % reducer.every === 0 ? [{ at: event.at, value: state.count }] : []; - } - if (reducer.kind === 'distinct_count') { - if (!matched) return []; - const value = resolveReference(reducer.distinctBy, context); - if (value === undefined || value === null) return []; - for (const [key, at] of state.distinctTimestamps) { - if (event.at - at > reducer.windowSeconds + EPSILON) state.distinctTimestamps.delete(key); - } - state.distinctTimestamps.set(String(value), event.at); - if (state.distinctTimestamps.size < reducer.threshold) return []; - const count = state.distinctTimestamps.size; - state.distinctTimestamps.clear(); - return [{ at: event.at, value: count }]; - } - if (reducer.kind === 'consecutive_count') { - if (!matched) { - state.count = 0; - state.previousGroup = null; - return []; - } - const value = reducer.groupBy ? resolveReference(reducer.groupBy, context) : 'global'; - if (value === undefined || value === null) { - state.count = 0; - state.previousGroup = null; - return []; - } - const group = String(value); - state.count = state.previousGroup === group ? state.count + 1 : 1; - state.previousGroup = group; - if (state.count < reducer.threshold) return []; - const count = state.count; - state.count = 0; - return [{ at: event.at, value: count }]; - } - if (reducer.kind === 'sliding_count') { - if (!matched) return []; - state.timestamps = state.timestamps.filter( - (at) => event.at - at <= reducer.windowSeconds + EPSILON - ); - state.timestamps.push(event.at); - state.count = state.timestamps.length; - if ((reducer.reset ?? 'zero') === 'never' && state.durationEmitted) return []; - if (state.count < reducer.threshold) return []; - const value = state.count; - const reset = reducer.reset ?? 'zero'; - if (reset === 'never') state.durationEmitted = true; - if (reset === 'zero') state.timestamps = []; - else if (reset === 'subtract') state.timestamps.splice(0, reducer.threshold); - state.count = state.timestamps.length; - return [{ at: event.at, value }]; - } - if (reducer.kind === 'duration') { - if (!matched) { - state.startedAt = null; - state.durationEmitted = false; - return []; - } - if (state.startedAt === null) state.startedAt = event.at; - const elapsed = event.at - state.startedAt; - if (elapsed + EPSILON < reducer.seconds) return []; - if (!reducer.repeat && state.durationEmitted) return []; - const index = Math.floor((elapsed + EPSILON) / reducer.seconds); - if (reducer.repeat && index <= state.lastPeriodicIndex) return []; - state.durationEmitted = true; - state.lastPeriodicIndex = index; - return [{ at: event.at, value: elapsed }]; - } - if (!matched) { - state.startedAt = null; - state.lastPeriodicIndex = -1; - return []; - } - if (state.startedAt === null) { - state.startedAt = event.at; - state.lastPeriodicIndex = reducer.immediate ? 0 : -1; - return reducer.immediate ? [{ at: event.at, value: 1 }] : []; - } - const intervalSeconds = numericValue(reducer.intervalSeconds, context); - if (!finiteNumber(intervalSeconds) || intervalSeconds <= 0) return []; - const elapsed = event.at - state.startedAt; - const index = Math.floor((elapsed + EPSILON) / intervalSeconds); - const firstIndex = Math.max(state.lastPeriodicIndex + 1, 1); - const emissions: ReducerEmission[] = []; - for (let current = firstIndex; current <= index; current += 1) { - emissions.push({ - at: state.startedAt + current * intervalSeconds, - value: current - }); - } - state.lastPeriodicIndex = Math.max(state.lastPeriodicIndex, index); - return emissions; + if (reducer.kind === 'none') + return matched ? [{ at: event.at, value: 1 }] : []; + if (reducer.kind === 'derived_scalar') { + return matched + ? [{ at: event.at, value: numericValue(reducer.value, context) }] + : []; + } + if (reducer.kind === 'count') { + if (!matched) return []; + state.count += 1; + if ((reducer.reset ?? 'zero') === 'never' && state.durationEmitted) + return []; + if (state.count < reducer.threshold) return []; + const value = state.count; + if ((reducer.reset ?? 'zero') === 'never') state.durationEmitted = true; + resetCount(state, reducer.threshold, reducer.reset ?? 'zero'); + return [{ at: event.at, value }]; + } + if (reducer.kind === 'modulo') { + if (!matched) return []; + state.count += 1; + return state.count % reducer.every === 0 + ? [{ at: event.at, value: state.count }] + : []; + } + if (reducer.kind === 'distinct_count') { + if (!matched) return []; + const value = resolveReference(reducer.distinctBy, context); + if (value === undefined || value === null) return []; + for (const [key, at] of state.distinctTimestamps) { + if (event.at - at > reducer.windowSeconds + EPSILON) + state.distinctTimestamps.delete(key); + } + state.distinctTimestamps.set(String(value), event.at); + if (state.distinctTimestamps.size < reducer.threshold) return []; + const count = state.distinctTimestamps.size; + state.distinctTimestamps.clear(); + return [{ at: event.at, value: count }]; + } + if (reducer.kind === 'consecutive_count') { + if (!matched) { + state.count = 0; + state.previousGroup = null; + return []; + } + const value = reducer.groupBy + ? resolveReference(reducer.groupBy, context) + : 'global'; + if (value === undefined || value === null) { + state.count = 0; + state.previousGroup = null; + return []; + } + const group = String(value); + state.count = state.previousGroup === group ? state.count + 1 : 1; + state.previousGroup = group; + if (state.count < reducer.threshold) return []; + const count = state.count; + state.count = 0; + return [{ at: event.at, value: count }]; + } + if (reducer.kind === 'sliding_count') { + if (!matched) return []; + state.timestamps = state.timestamps.filter( + (at) => event.at - at <= reducer.windowSeconds + EPSILON, + ); + state.timestamps.push(event.at); + state.count = state.timestamps.length; + if ((reducer.reset ?? 'zero') === 'never' && state.durationEmitted) + return []; + if (state.count < reducer.threshold) return []; + const value = state.count; + const reset = reducer.reset ?? 'zero'; + if (reset === 'never') state.durationEmitted = true; + if (reset === 'zero') state.timestamps = []; + else if (reset === 'subtract') + state.timestamps.splice(0, reducer.threshold); + state.count = state.timestamps.length; + return [{ at: event.at, value }]; + } + if (reducer.kind === 'duration') { + if (!matched) { + state.startedAt = null; + state.durationEmitted = false; + return []; + } + if (state.startedAt === null) state.startedAt = event.at; + const elapsed = event.at - state.startedAt; + if (elapsed + EPSILON < reducer.seconds) return []; + if (!reducer.repeat && state.durationEmitted) return []; + const index = Math.floor((elapsed + EPSILON) / reducer.seconds); + if (reducer.repeat && index <= state.lastPeriodicIndex) return []; + state.durationEmitted = true; + state.lastPeriodicIndex = index; + return [{ at: event.at, value: elapsed }]; + } + if (!matched) { + state.startedAt = null; + state.lastPeriodicIndex = -1; + return []; + } + if (state.startedAt === null) { + state.startedAt = event.at; + state.lastPeriodicIndex = reducer.immediate ? 0 : -1; + return reducer.immediate ? [{ at: event.at, value: 1 }] : []; + } + const intervalSeconds = numericValue(reducer.intervalSeconds, context); + if (!finiteNumber(intervalSeconds) || intervalSeconds <= 0) return []; + const elapsed = event.at - state.startedAt; + const index = Math.floor((elapsed + EPSILON) / intervalSeconds); + const firstIndex = Math.max(state.lastPeriodicIndex + 1, 1); + const emissions: ReducerEmission[] = []; + for (let current = firstIndex; current <= index; current += 1) { + emissions.push({ + at: state.startedAt + current * intervalSeconds, + value: current, + }); + } + state.lastPeriodicIndex = Math.max(state.lastPeriodicIndex, index); + return emissions; } function setPoolValue( - definition: PoolDefinition, - entity: string, - value: number, - at: number, - pools: Map + definition: PoolDefinition, + entity: string, + value: number, + at: number, + pools: Map, ): { before: number; after: number } { - const key = poolStorageKey(definition.key, entity); - const current = pools.get(key); - const before = current?.value ?? definition.initial; - const after = clamp( - Number.isFinite(value) ? value : before, - definition.min ?? Number.NEGATIVE_INFINITY, - definition.max ?? Number.POSITIVE_INFINITY - ); - pools.set(key, { - pool: definition.key, - scope: definition.scope, - entityId: entity, - value: after, - expiresAt: definition.expiresAfterSeconds === undefined - ? null - : at + definition.expiresAfterSeconds - }); - return { before, after }; + const key = poolStorageKey(definition.key, entity); + const current = pools.get(key); + const before = current?.value ?? definition.initial; + const after = clamp( + Number.isFinite(value) ? value : before, + definition.min ?? Number.NEGATIVE_INFINITY, + definition.max ?? Number.POSITIVE_INFINITY, + ); + pools.set(key, { + pool: definition.key, + scope: definition.scope, + entityId: entity, + value: after, + expiresAt: + definition.expiresAfterSeconds === undefined + ? null + : at + definition.expiresAfterSeconds, + }); + return { before, after }; } function applyMutation( - mutation: ClauseMutation, - at: number, - context: EvaluationContext, - weight = 1 + mutation: ClauseMutation, + at: number, + context: EvaluationContext, + weight = 1, ): MutationRecord { - if (mutation.kind === 'none') return { kind: 'none' }; - const definition = context.poolDefinitions.get(mutation.pool); - if (!definition) return { kind: mutation.kind, pool: mutation.pool }; - const mutationWeight = clamp(weight, 0, 1); - if (mutation.kind === 'transfer') { - const from = entityId(mutation.from, context.event, definition.scope); - const to = entityId(mutation.to, context.event, definition.scope); - if (!from || !to) return { kind: 'transfer', pool: mutation.pool }; - const sourceBefore = stateValue(mutation.pool, mutation.from, context); - const targetBefore = stateValue(mutation.pool, mutation.to, context); - const requested = mutation.value === undefined || mutation.value === 'all' - ? sourceBefore - : numericValue(mutation.value, context); - const amount = clamp(requested, 0, sourceBefore) * mutationWeight; - const sourceAfter = mutation.clearSource === false ? sourceBefore : sourceBefore - amount; - const targetAfter = targetBefore + amount; - if (mutationWeight > 0) { - setPoolValue(definition, from, sourceAfter, at, context.pools); - setPoolValue(definition, to, targetAfter, at, context.pools); - } - return { - kind: 'transfer', - pool: mutation.pool, - entityId: from, - before: sourceBefore, - after: mutationWeight > 0 ? stateValue(mutation.pool, mutation.from, context) : sourceBefore, - targetEntityId: to, - targetBefore, - targetAfter: mutationWeight > 0 - ? stateValue(mutation.pool, mutation.to, context) - : targetBefore - }; - } - const entity = entityId(mutation.entity, context.event, definition.scope); - if (!entity) return { kind: mutation.kind, pool: mutation.pool }; - const before = stateValue(mutation.pool, mutation.entity, context); - let requested = before; - if (mutation.kind === 'clear') requested = definition.initial; - else { - const value = numericValue(mutation.value, context); - if (mutation.kind === 'add') requested = before + value; - else if (mutation.kind === 'consume') requested = before - value; - else requested = value; - } - const weightedRequest = before + (requested - before) * mutationWeight; - const changed = mutationWeight > 0 - ? setPoolValue(definition, entity, weightedRequest, at, context.pools) - : { before, after: before }; - return { - kind: mutation.kind, - pool: mutation.pool, - entityId: entity, - before: changed.before, - after: changed.after - }; + if (mutation.kind === 'none') return { kind: 'none' }; + const definition = context.poolDefinitions.get(mutation.pool); + if (!definition) return { kind: mutation.kind, pool: mutation.pool }; + const mutationWeight = clamp(weight, 0, 1); + if (mutation.kind === 'transfer') { + const from = entityId(mutation.from, context.event, definition.scope); + const to = entityId(mutation.to, context.event, definition.scope); + if (!from || !to) return { kind: 'transfer', pool: mutation.pool }; + const sourceBefore = stateValue(mutation.pool, mutation.from, context); + const targetBefore = stateValue(mutation.pool, mutation.to, context); + const requested = + mutation.value === undefined || mutation.value === 'all' + ? sourceBefore + : numericValue(mutation.value, context); + const amount = clamp(requested, 0, sourceBefore) * mutationWeight; + const sourceAfter = + mutation.clearSource === false ? sourceBefore : sourceBefore - amount; + const targetAfter = targetBefore + amount; + if (mutationWeight > 0) { + setPoolValue(definition, from, sourceAfter, at, context.pools); + setPoolValue(definition, to, targetAfter, at, context.pools); + } + return { + kind: 'transfer', + pool: mutation.pool, + entityId: from, + before: sourceBefore, + after: + mutationWeight > 0 + ? stateValue(mutation.pool, mutation.from, context) + : sourceBefore, + targetEntityId: to, + targetBefore, + targetAfter: + mutationWeight > 0 + ? stateValue(mutation.pool, mutation.to, context) + : targetBefore, + }; + } + const entity = entityId(mutation.entity, context.event, definition.scope); + if (!entity) return { kind: mutation.kind, pool: mutation.pool }; + const before = stateValue(mutation.pool, mutation.entity, context); + let requested = before; + if (mutation.kind === 'clear') requested = definition.initial; + else { + const value = numericValue(mutation.value, context); + if (mutation.kind === 'add') requested = before + value; + else if (mutation.kind === 'consume') requested = before - value; + else requested = value; + } + const weightedRequest = before + (requested - before) * mutationWeight; + const changed = + mutationWeight > 0 + ? setPoolValue(definition, entity, weightedRequest, at, context.pools) + : { before, after: before }; + return { + kind: mutation.kind, + pool: mutation.pool, + entityId: entity, + before: changed.before, + after: changed.after, + }; } -function outcomeState(outcome: MechanicOutcome, context: EvaluationContext): number { - return outcome.statePool ? stateValue(outcome.statePool, undefined, context) : 1; +function outcomeState( + outcome: MechanicOutcome, + context: EvaluationContext, +): number { + return outcome.statePool + ? stateValue(outcome.statePool, undefined, context) + : 1; } function phaseMatches( - phase: OutcomePhase, - stateBefore: number, - stateAfter: number + phase: OutcomePhase, + stateBefore: number, + stateAfter: number, ) { - if (phase === 'on_event' || phase === 'after_event') return true; - if (phase === 'on_enter') return stateBefore <= 0 && stateAfter > 0; - if (phase === 'on_exit') return stateBefore > 0 && stateAfter <= 0; - return stateAfter > 0; + if (phase === 'on_event' || phase === 'after_event') return true; + if (phase === 'on_enter') return stateBefore <= 0 && stateAfter > 0; + if (phase === 'on_exit') return stateBefore > 0 && stateAfter <= 0; + return stateAfter > 0; } -function copyCombat(combat: MechanicCombatContext | null): MechanicCombatContext | null { - return combat ? { - ...combat, - attributes: combat.attributes ? { ...combat.attributes } : undefined, - values: combat.values ? { ...combat.values } : undefined - } : null; +function copyCombat( + combat: MechanicCombatContext | null, +): MechanicCombatContext | null { + return combat + ? { + ...combat, + attributes: combat.attributes ? { ...combat.attributes } : undefined, + values: combat.values ? { ...combat.values } : undefined, + } + : null; } function resolveOutcomeValue( - outcome: MechanicOutcome, - value: number, - context: EvaluationContext + outcome: MechanicOutcome, + value: number, + context: EvaluationContext, ): number { - if (outcome.basis === 'flat') return value; - const combat = outcome.combatTiming === 'pre_event' - ? context.preEventCombat - : context.combat; - if (!combat) return 0; - if (outcome.basis === 'max_health_percent') return combat.maxHealth * value / 100; - if (outcome.basis === 'current_health_percent') return combat.currentHealth * value / 100; - return (combat.weaponBaseDamage ?? 0) * value / 100; + if (outcome.basis === 'flat') return value; + const combat = + outcome.combatTiming === 'pre_event' + ? context.preEventCombat + : context.combat; + if (!combat) return 0; + if (outcome.basis === 'max_health_percent') + return (combat.maxHealth * value) / 100; + if (outcome.basis === 'current_health_percent') + return (combat.currentHealth * value) / 100; + return ((combat.weaponBaseDamage ?? 0) * value) / 100; } function outcomeResource(outcome: MechanicOutcome): MechanicResource { - return outcome.resource ?? 'health'; + return outcome.resource ?? 'health'; } function combatResource( - combat: MechanicCombatContext, - resource: MechanicResource -): { current: number; maximum: number; setCurrent: (value: number) => void } | null { - if (resource === 'health') { - return { - current: combat.currentHealth, - maximum: combat.maxHealth, - setCurrent: (value) => { combat.currentHealth = value; } - }; - } - if (resource === 'armor' && finiteNumber(combat.currentArmor) && finiteNumber(combat.maxArmor)) { - return { - current: combat.currentArmor, - maximum: combat.maxArmor, - setCurrent: (value) => { combat.currentArmor = value; } - }; - } - if (resource === 'ammo' && finiteNumber(combat.currentAmmo) && finiteNumber(combat.maxAmmo)) { - return { - current: combat.currentAmmo, - maximum: combat.maxAmmo, - setCurrent: (value) => { combat.currentAmmo = value; } - }; - } - if (resource === 'signature_ability_charge') { - const maximum = finiteNumber(combat.maxSignatureAbilityCharge) - ? combat.maxSignatureAbilityCharge - : 1000; - const current = finiteNumber(combat.currentSignatureAbilityCharge) - ? combat.currentSignatureAbilityCharge - : 0; - return { - current, - maximum, - setCurrent: (value) => { combat.currentSignatureAbilityCharge = value; } - }; - } - return null; + combat: MechanicCombatContext, + resource: MechanicResource, +): { + current: number; + maximum: number; + setCurrent: (value: number) => void; +} | null { + if (resource === 'health') { + return { + current: combat.currentHealth, + maximum: combat.maxHealth, + setCurrent: (value) => { + combat.currentHealth = value; + }, + }; + } + if ( + resource === 'armor' && + finiteNumber(combat.currentArmor) && + finiteNumber(combat.maxArmor) + ) { + return { + current: combat.currentArmor, + maximum: combat.maxArmor, + setCurrent: (value) => { + combat.currentArmor = value; + }, + }; + } + if ( + resource === 'ammo' && + finiteNumber(combat.currentAmmo) && + finiteNumber(combat.maxAmmo) + ) { + return { + current: combat.currentAmmo, + maximum: combat.maxAmmo, + setCurrent: (value) => { + combat.currentAmmo = value; + }, + }; + } + if (resource === 'signature_ability_charge') { + const maximum = finiteNumber(combat.maxSignatureAbilityCharge) + ? combat.maxSignatureAbilityCharge + : 1000; + const current = finiteNumber(combat.currentSignatureAbilityCharge) + ? combat.currentSignatureAbilityCharge + : 0; + return { + current, + maximum, + setCurrent: (value) => { + combat.currentSignatureAbilityCharge = value; + }, + }; + } + return null; } function emitOutcomes( - clause: MechanicClause, - phase: 'before' | 'after', - at: number, - weight: number, - before: Map, - context: EvaluationContext, - continuousDeliveries?: ContinuousDelivery[] + clause: MechanicClause, + phase: 'before' | 'after', + at: number, + weight: number, + before: Map, + context: EvaluationContext, + continuousDeliveries?: ContinuousDelivery[], ): MechanicOutcomeEmission[] { - const emissions: MechanicOutcomeEmission[] = []; - const stateValues = (outcome: MechanicOutcome) => { - const current = outcomeState(outcome, context); - const previous = outcome.statePool ? before.get(outcome.statePool) ?? current : current; - return { - current, - previous, - effective: outcome.phase === 'on_exit' ? previous : current - }; - }; - const eligible = (clause.outcomes ?? []).filter((outcome) => { - if (phase === 'before' && outcome.phase !== 'on_event') return false; - if (phase === 'after' && outcome.phase === 'on_event') return false; - const { current, previous, effective } = stateValues(outcome); - if (!phaseMatches(outcome.phase, previous, current)) return false; - if (outcome.minStacks !== undefined && effective < outcome.minStacks) return false; - if (outcome.maxStacks !== undefined && effective > outcome.maxStacks) return false; - return true; - }).sort((left, right) => { - const priority = (outcome: MechanicOutcome) => - outcome.kind === 'resource_cost' ? 0 : outcome.kind === 'restoration' ? 2 : 1; - return priority(left) - priority(right); - }); - for (const outcome of eligible) { - const { effective: current } = stateValues(outcome); - const baseValue = numericValue(outcome.value, context); - const value = outcome.perStack ? baseValue * current : baseValue; - const resolvedValue = resolveOutcomeValue(outcome, value, context); - const combatBefore = copyCombat(context.combat); - let appliedValue = resolvedValue * weight; - const resource = outcome.kind === 'restoration' || outcome.kind === 'resource_cost' - ? outcomeResource(outcome) - : null; - const targetResource = context.combat && resource ? combatResource(context.combat, resource) : null; - if (outcome.kind === 'restoration' && outcome.delivery === 'continuous') { - const durationSeconds = outcome.durationSeconds as number; - continuousDeliveries?.push({ - clauseId: clause.id, - outcomeId: outcome.id, - targetId: context.event.targetId ?? null, - phase: outcome.phase, - scope: outcome.scope, - resource: outcomeResource(outcome), - attributeId: outcome.attributeId ?? null, - basis: outcome.basis, - combatTiming: outcome.combatTiming ?? 'current', - value, - totalResolvedValue: Math.max(0, resolvedValue * weight), - weight, - stateValue: current, - start: at, - end: at + durationSeconds, - appliedThrough: at, - targeting: outcome.targeting ?? null - }); - continue; - } - if (outcome.kind === 'resource_cost' && targetResource) { - appliedValue = Math.min(targetResource.current, Math.max(0, appliedValue)); - targetResource.setCurrent(targetResource.current - appliedValue); - } else if (outcome.kind === 'restoration' && targetResource) { - appliedValue = Math.min( - targetResource.maximum - targetResource.current, - Math.max(0, appliedValue) - ); - targetResource.setCurrent(targetResource.current + appliedValue); - } else if ((outcome.kind === 'resource_cost' || outcome.kind === 'restoration') && !targetResource) { - appliedValue = 0; - } - const emittedAt = outcome.kind === 'weapon_reload' - ? at + Math.max(0, appliedValue) - : at; - emissions.push({ - clauseId: clause.id, - outcomeId: outcome.id, - at: emittedAt, - targetId: context.event.targetId ?? null, - phase: outcome.phase, - kind: outcome.kind, - scope: outcome.scope, - resource, - attributeId: outcome.attributeId ?? null, - basis: outcome.basis, - combatTiming: outcome.combatTiming ?? 'current', - value, - resolvedValue, - appliedValue, - weight, - stateValue: current, - combatBefore, - combatAfter: copyCombat(context.combat), - delivery: 'instant', - deliveryStart: null, - deliveryEnd: null, - totalResolvedValue: resolvedValue * weight, - targeting: outcome.targeting ?? null - }); - } - return emissions; + const emissions: MechanicOutcomeEmission[] = []; + const stateValues = (outcome: MechanicOutcome) => { + const current = outcomeState(outcome, context); + const previous = outcome.statePool + ? (before.get(outcome.statePool) ?? current) + : current; + return { + current, + previous, + effective: outcome.phase === 'on_exit' ? previous : current, + }; + }; + const eligible = (clause.outcomes ?? []) + .filter((outcome) => { + if (phase === 'before' && outcome.phase !== 'on_event') return false; + if (phase === 'after' && outcome.phase === 'on_event') return false; + const { current, previous, effective } = stateValues(outcome); + if (!phaseMatches(outcome.phase, previous, current)) return false; + if (outcome.minStacks !== undefined && effective < outcome.minStacks) + return false; + if (outcome.maxStacks !== undefined && effective > outcome.maxStacks) + return false; + return true; + }) + .sort((left, right) => { + const priority = (outcome: MechanicOutcome) => + outcome.kind === 'resource_cost' + ? 0 + : outcome.kind === 'restoration' + ? 2 + : 1; + return priority(left) - priority(right); + }); + for (const outcome of eligible) { + const { effective: current } = stateValues(outcome); + const baseValue = numericValue(outcome.value, context); + const value = outcome.perStack ? baseValue * current : baseValue; + const resolvedValue = resolveOutcomeValue(outcome, value, context); + const combatBefore = copyCombat(context.combat); + let appliedValue = resolvedValue * weight; + const resource = + outcome.kind === 'restoration' || outcome.kind === 'resource_cost' + ? outcomeResource(outcome) + : null; + const targetResource = + context.combat && resource + ? combatResource(context.combat, resource) + : null; + if (outcome.kind === 'restoration' && outcome.delivery === 'continuous') { + const durationSeconds = outcome.durationSeconds as number; + continuousDeliveries?.push({ + clauseId: clause.id, + outcomeId: outcome.id, + targetId: context.event.targetId ?? null, + phase: outcome.phase, + scope: outcome.scope, + resource: outcomeResource(outcome), + attributeId: outcome.attributeId ?? null, + basis: outcome.basis, + combatTiming: outcome.combatTiming ?? 'current', + value, + totalResolvedValue: Math.max(0, resolvedValue * weight), + weight, + stateValue: current, + start: at, + end: at + durationSeconds, + appliedThrough: at, + targeting: outcome.targeting ?? null, + }); + continue; + } + if (outcome.kind === 'resource_cost' && targetResource) { + appliedValue = Math.min( + targetResource.current, + Math.max(0, appliedValue), + ); + targetResource.setCurrent(targetResource.current - appliedValue); + } else if (outcome.kind === 'restoration' && targetResource) { + appliedValue = Math.min( + targetResource.maximum - targetResource.current, + Math.max(0, appliedValue), + ); + targetResource.setCurrent(targetResource.current + appliedValue); + } else if ( + (outcome.kind === 'resource_cost' || outcome.kind === 'restoration') && + !targetResource + ) { + appliedValue = 0; + } + const emittedAt = + outcome.kind === 'weapon_reload' ? at + Math.max(0, appliedValue) : at; + emissions.push({ + clauseId: clause.id, + outcomeId: outcome.id, + at: emittedAt, + targetId: context.event.targetId ?? null, + phase: outcome.phase, + kind: outcome.kind, + scope: outcome.scope, + resource, + attributeId: outcome.attributeId ?? null, + basis: outcome.basis, + combatTiming: outcome.combatTiming ?? 'current', + value, + resolvedValue, + appliedValue, + weight, + stateValue: current, + combatBefore, + combatAfter: copyCombat(context.combat), + delivery: 'instant', + deliveryStart: null, + deliveryEnd: null, + totalResolvedValue: resolvedValue * weight, + targeting: outcome.targeting ?? null, + }); + } + return emissions; } function appendScheduledOutcomes( - target: MechanicOutcomeEmission[], - candidates: MechanicOutcomeEmission[], - triggeredAt: number, - encounterSeconds: number + target: MechanicOutcomeEmission[], + candidates: MechanicOutcomeEmission[], + triggeredAt: number, + encounterSeconds: number, ) { - for (const candidate of candidates) { - if (candidate.kind !== 'weapon_reload') { - target.push(candidate); - continue; - } - for (let index = target.length - 1; index >= 0; index -= 1) { - const pending = target[index]; - if ( - pending.kind === 'weapon_reload' - && pending.clauseId === candidate.clauseId - && pending.outcomeId === candidate.outcomeId - && pending.scope === candidate.scope - && pending.at > triggeredAt + EPSILON - ) { - target.splice(index, 1); - } - } - if (candidate.at <= encounterSeconds + EPSILON) target.push(candidate); - } + for (const candidate of candidates) { + if (candidate.kind !== 'weapon_reload') { + target.push(candidate); + continue; + } + for (let index = target.length - 1; index >= 0; index -= 1) { + const pending = target[index]; + if ( + pending.kind === 'weapon_reload' && + pending.clauseId === candidate.clauseId && + pending.outcomeId === candidate.outcomeId && + pending.scope === candidate.scope && + pending.at > triggeredAt + EPSILON + ) { + target.splice(index, 1); + } + } + if (candidate.at <= encounterSeconds + EPSILON) target.push(candidate); + } } function advanceContinuousDeliveries( - at: number, - deliveries: ContinuousDelivery[], - combat: MechanicCombatContext | null + at: number, + deliveries: ContinuousDelivery[], + combat: MechanicCombatContext | null, ): MechanicOutcomeEmission[] { - const emissions: MechanicOutcomeEmission[] = []; - for (const delivery of deliveries) { - const through = Math.min(at, delivery.end); - const elapsed = through - delivery.appliedThrough; - if (elapsed <= EPSILON) continue; - const duration = delivery.end - delivery.start; - const resolvedValue = delivery.totalResolvedValue * elapsed / duration; - const combatBefore = copyCombat(combat); - const targetResource = combat ? combatResource(combat, delivery.resource) : null; - let appliedValue = 0; - if (targetResource) { - appliedValue = Math.min( - targetResource.maximum - targetResource.current, - Math.max(0, resolvedValue) - ); - targetResource.setCurrent(targetResource.current + appliedValue); - } - delivery.appliedThrough = through; - emissions.push({ - clauseId: delivery.clauseId, - outcomeId: delivery.outcomeId, - at: through, - targetId: delivery.targetId, - phase: delivery.phase, - kind: 'restoration', - scope: delivery.scope, - resource: delivery.resource, - attributeId: delivery.attributeId, - basis: delivery.basis, - combatTiming: delivery.combatTiming, - value: delivery.value, - resolvedValue, - appliedValue, - weight: delivery.weight, - stateValue: delivery.stateValue, - combatBefore, - combatAfter: copyCombat(combat), - delivery: 'continuous', - deliveryStart: delivery.start, - deliveryEnd: delivery.end, - totalResolvedValue: delivery.totalResolvedValue, - targeting: delivery.targeting - }); - } - for (let index = deliveries.length - 1; index >= 0; index -= 1) { - if (deliveries[index].appliedThrough >= deliveries[index].end - EPSILON) { - deliveries.splice(index, 1); - } - } - return emissions; + const emissions: MechanicOutcomeEmission[] = []; + for (const delivery of deliveries) { + const through = Math.min(at, delivery.end); + const elapsed = through - delivery.appliedThrough; + if (elapsed <= EPSILON) continue; + const duration = delivery.end - delivery.start; + const resolvedValue = (delivery.totalResolvedValue * elapsed) / duration; + const combatBefore = copyCombat(combat); + const targetResource = combat + ? combatResource(combat, delivery.resource) + : null; + let appliedValue = 0; + if (targetResource) { + appliedValue = Math.min( + targetResource.maximum - targetResource.current, + Math.max(0, resolvedValue), + ); + targetResource.setCurrent(targetResource.current + appliedValue); + } + delivery.appliedThrough = through; + emissions.push({ + clauseId: delivery.clauseId, + outcomeId: delivery.outcomeId, + at: through, + targetId: delivery.targetId, + phase: delivery.phase, + kind: 'restoration', + scope: delivery.scope, + resource: delivery.resource, + attributeId: delivery.attributeId, + basis: delivery.basis, + combatTiming: delivery.combatTiming, + value: delivery.value, + resolvedValue, + appliedValue, + weight: delivery.weight, + stateValue: delivery.stateValue, + combatBefore, + combatAfter: copyCombat(combat), + delivery: 'continuous', + deliveryStart: delivery.start, + deliveryEnd: delivery.end, + totalResolvedValue: delivery.totalResolvedValue, + targeting: delivery.targeting, + }); + } + for (let index = deliveries.length - 1; index >= 0; index -= 1) { + if (deliveries[index].appliedThrough >= deliveries[index].end - EPSILON) { + deliveries.splice(index, 1); + } + } + return emissions; } function expirePools( - at: number, - clauses: MechanicClause[], - scenario: Record, - pools: Map, - definitions: Map, - combat: MechanicCombatContext | null, - equipment: MechanicEquipmentContext | null, - continuousDeliveries: ContinuousDelivery[] + at: number, + clauses: MechanicClause[], + scenario: Record, + pools: Map, + definitions: Map, + combat: MechanicCombatContext | null, + equipment: MechanicEquipmentContext | null, + continuousDeliveries: ContinuousDelivery[], ): MechanicOutcomeEmission[] { - const expired = [...pools.entries()] - .filter(([, state]) => state.expiresAt !== null && state.expiresAt <= at + EPSILON) - .sort((left, right) => - (left[1].expiresAt ?? 0) - (right[1].expiresAt ?? 0) - || left[0].localeCompare(right[0])); - const emissions: MechanicOutcomeEmission[] = []; - let index = 0; - while (index < expired.length) { - const expiryAt = expired[index][1].expiresAt as number; - emissions.push(...advanceContinuousDeliveries(expiryAt, continuousDeliveries, combat)); - const group: typeof expired = []; - while ( - index < expired.length - && Math.abs((expired[index][1].expiresAt as number) - expiryAt) <= EPSILON - ) { - group.push(expired[index]); - index += 1; - } - const preEventCombat = copyCombat(combat); - for (const [key, state] of group) { - const definition = definitions.get(state.pool); - if (!definition) continue; - pools.set(key, { ...state, value: definition.initial, expiresAt: null }); - } - for (const [, state] of group) { - const definition = definitions.get(state.pool); - if (!definition) continue; - const event: MechanicEvent = { - kind: 'tick', - at: expiryAt, - actorId: AGENT_ENTITY, - targetId: definition.scope === 'target' ? state.entityId : DEFAULT_TARGET_ENTITY - }; - const context: EvaluationContext = { - event, - scenario, - reducerValue: 0, - pools, - poolDefinitions: definitions, - combat, - preEventCombat, - equipment - }; - const before = new Map([[state.pool, state.value]]); - for (const clause of clauses) { - const exitOutcomes = (clause.outcomes ?? []).filter((outcome) => - outcome.phase === 'on_exit' && outcome.statePool === state.pool); - if (!exitOutcomes.length) continue; - emissions.push(...emitOutcomes( - { ...clause, outcomes: exitOutcomes }, - 'after', - expiryAt, - 1, - before, - context, - continuousDeliveries - )); - } - } - } - return emissions; + const expired = [...pools.entries()] + .filter( + ([, state]) => + state.expiresAt !== null && state.expiresAt <= at + EPSILON, + ) + .sort( + (left, right) => + (left[1].expiresAt ?? 0) - (right[1].expiresAt ?? 0) || + left[0].localeCompare(right[0]), + ); + const emissions: MechanicOutcomeEmission[] = []; + let index = 0; + while (index < expired.length) { + const expiryAt = expired[index][1].expiresAt as number; + emissions.push( + ...advanceContinuousDeliveries(expiryAt, continuousDeliveries, combat), + ); + const group: typeof expired = []; + while ( + index < expired.length && + Math.abs((expired[index][1].expiresAt as number) - expiryAt) <= EPSILON + ) { + group.push(expired[index]); + index += 1; + } + const preEventCombat = copyCombat(combat); + for (const [key, state] of group) { + const definition = definitions.get(state.pool); + if (!definition) continue; + pools.set(key, { ...state, value: definition.initial, expiresAt: null }); + } + for (const [, state] of group) { + const definition = definitions.get(state.pool); + if (!definition) continue; + const event: MechanicEvent = { + kind: 'tick', + at: expiryAt, + actorId: AGENT_ENTITY, + targetId: + definition.scope === 'target' + ? state.entityId + : DEFAULT_TARGET_ENTITY, + }; + const context: EvaluationContext = { + event, + scenario, + reducerValue: 0, + pools, + poolDefinitions: definitions, + combat, + preEventCombat, + equipment, + }; + const before = new Map([[state.pool, state.value]]); + for (const clause of clauses) { + const exitOutcomes = (clause.outcomes ?? []).filter( + (outcome) => + outcome.phase === 'on_exit' && outcome.statePool === state.pool, + ); + if (!exitOutcomes.length) continue; + emissions.push( + ...emitOutcomes( + { ...clause, outcomes: exitOutcomes }, + 'after', + expiryAt, + 1, + before, + context, + continuousDeliveries, + ), + ); + } + } + } + return emissions; } function initialPools( - definitions: Map, - mode: CalculationMode + definitions: Map, + mode: CalculationMode, ): Map { - const pools = new Map(); - for (const definition of definitions.values()) { - if (mode === 'custom' && definition.scope === 'target') continue; - const entity = definition.scope === 'agent' ? AGENT_ENTITY : DEFAULT_TARGET_ENTITY; - const value = mode === 'maximum' - ? definition.bestCaseValue ?? definition.max ?? definition.initial - : definition.initial; - pools.set(poolStorageKey(definition.key, entity), { - pool: definition.key, - scope: definition.scope, - entityId: entity, - value, - expiresAt: null - }); - } - return pools; + const pools = new Map(); + for (const definition of definitions.values()) { + if (mode === 'custom' && definition.scope === 'target') continue; + const entity = + definition.scope === 'agent' ? AGENT_ENTITY : DEFAULT_TARGET_ENTITY; + const value = + mode === 'maximum' + ? (definition.bestCaseValue ?? definition.max ?? definition.initial) + : definition.initial; + pools.set(poolStorageKey(definition.key, entity), { + pool: definition.key, + scope: definition.scope, + entityId: entity, + value, + expiresAt: null, + }); + } + return pools; } function invalidEvaluation( - program: MechanicProgram, - scenario: MechanicScenario, - errors: string[] + program: MechanicProgram, + scenario: MechanicScenario, + errors: string[], ): MechanicEvaluation { - return { - status: 'INVALID', - errors, - programId: program.id, - mode: scenario.mode, - scenarioInputs: {}, - readiness: { status: 'REQUIRES_ASSUMPTIONS', missingInputs: [] }, - emissions: [], - outcomes: [], - finalState: [], - finalCombat: null, - equipment: null, - estimatedActivationCount: 0, - script: formatMechanicProgram(program) - }; + return { + status: 'INVALID', + errors, + programId: program.id, + mode: scenario.mode, + scenarioInputs: {}, + readiness: { status: 'REQUIRES_ASSUMPTIONS', missingInputs: [] }, + emissions: [], + outcomes: [], + finalState: [], + finalCombat: null, + equipment: null, + estimatedActivationCount: 0, + script: formatMechanicProgram(program), + }; } -function validateReference(reference: ValueReference, pools: Set, inputs: Set, errors: string[], path: string) { - if (reference.source === 'pool' && !pools.has(reference.pool)) { - errors.push(`${path} references unknown pool "${reference.pool}".`); - } - if (reference.source === 'scenario' && !inputs.has(reference.key)) { - errors.push(`${path} references unknown scenario input "${reference.key}".`); - } - if (reference.source === 'event' && !reference.path.trim()) errors.push(`${path} requires an event path.`); - if (reference.source === 'combat') { - if (!reference.path.trim()) errors.push(`${path} requires a combat path.`); - if (reference.path === 'currentHealthPercent' && reference.timing === 'pre_event') { - errors.push(`${path} current Health percentage must use current timing.`); - } - } - if (reference.source === 'attribute' && !reference.attributeId.trim()) errors.push(`${path} requires an Attribute ID.`); - if (reference.source === 'equipment' && !reference.path.trim()) errors.push(`${path} requires an equipment path.`); +function validateReference( + reference: ValueReference, + pools: Set, + inputs: Set, + errors: string[], + path: string, +) { + if (reference.source === 'pool' && !pools.has(reference.pool)) { + errors.push(`${path} references unknown pool "${reference.pool}".`); + } + if (reference.source === 'scenario' && !inputs.has(reference.key)) { + errors.push( + `${path} references unknown scenario input "${reference.key}".`, + ); + } + if (reference.source === 'event' && !reference.path.trim()) + errors.push(`${path} requires an event path.`); + if (reference.source === 'combat') { + if (!reference.path.trim()) errors.push(`${path} requires a combat path.`); + if ( + reference.path === 'currentHealthPercent' && + reference.timing === 'pre_event' + ) { + errors.push(`${path} current Health percentage must use current timing.`); + } + } + if (reference.source === 'attribute' && !reference.attributeId.trim()) + errors.push(`${path} requires an Attribute ID.`); + if (reference.source === 'equipment' && !reference.path.trim()) + errors.push(`${path} requires an equipment path.`); } function validateStrictEventReferences( - value: unknown, - eventKind: MechanicEventKind, - errors: string[], - path: string + value: unknown, + eventKind: MechanicEventKind, + errors: string[], + path: string, ) { - const definition = MECHANIC_EVENT_REGISTRY[eventKind]; - if (!definition?.strictFields || value === null || typeof value !== 'object') return; - if ( - 'source' in value - && value.source === 'event' - && 'path' in value - && typeof value.path === 'string' - && !definition.fields?.some((field) => field.path === value.path) - ) { - errors.push(`${path} references event field "${value.path}" that is not defined for ${definition.label}.`); - } - for (const [key, child] of Object.entries(value)) { - validateStrictEventReferences(child, eventKind, errors, `${path} ${key}`); - } + const definition = MECHANIC_EVENT_REGISTRY[eventKind]; + if (!definition?.strictFields || value === null || typeof value !== 'object') + return; + if ( + 'source' in value && + value.source === 'event' && + 'path' in value && + typeof value.path === 'string' && + !definition.fields?.some((field) => field.path === value.path) + ) { + errors.push( + `${path} references event field "${value.path}" that is not defined for ${definition.label}.`, + ); + } + for (const [key, child] of Object.entries(value)) { + validateStrictEventReferences(child, eventKind, errors, `${path} ${key}`); + } } function validateStrictEventPredicate( - predicate: Predicate | undefined, - eventKind: MechanicEventKind, - errors: string[], - path: string + predicate: Predicate | undefined, + eventKind: MechanicEventKind, + errors: string[], + path: string, ) { - const definition = MECHANIC_EVENT_REGISTRY[eventKind]; - if (!definition?.strictFields || !predicate) return; - if (predicate.kind === 'not') { - validateStrictEventPredicate(predicate.predicate, eventKind, errors, `${path} NOT`); - return; - } - if (predicate.kind === 'all' || predicate.kind === 'any') { - predicate.predicates.forEach((item, index) => - validateStrictEventPredicate(item, eventKind, errors, `${path} ${index + 1}`)); - return; - } - if (predicate.kind !== 'compare') return; - const eventReference = typeof predicate.left === 'object' && predicate.left?.source === 'event' - ? predicate.left - : typeof predicate.right === 'object' && predicate.right?.source === 'event' - ? predicate.right - : null; - const scalar = eventReference === predicate.left ? predicate.right : predicate.left; - if (!eventReference || typeof scalar === 'object') return; - const field = definition.fields?.find((item) => item.path === eventReference.path); - if (!field || scalar === null) return; - if (typeof scalar !== field.type) { - errors.push(`${path} must compare ${field.label} with a ${field.type} value.`); - } - if (field.type !== 'number' && !['eq', 'ne'].includes(predicate.operator)) { - errors.push(`${path} must use equality comparison for ${field.label}.`); - } + const definition = MECHANIC_EVENT_REGISTRY[eventKind]; + if (!definition?.strictFields || !predicate) return; + if (predicate.kind === 'not') { + validateStrictEventPredicate( + predicate.predicate, + eventKind, + errors, + `${path} NOT`, + ); + return; + } + if (predicate.kind === 'all' || predicate.kind === 'any') { + predicate.predicates.forEach((item, index) => + validateStrictEventPredicate( + item, + eventKind, + errors, + `${path} ${index + 1}`, + ), + ); + return; + } + if (predicate.kind !== 'compare') return; + const eventReference = + typeof predicate.left === 'object' && predicate.left?.source === 'event' + ? predicate.left + : typeof predicate.right === 'object' && + predicate.right?.source === 'event' + ? predicate.right + : null; + const scalar = + eventReference === predicate.left ? predicate.right : predicate.left; + if (!eventReference || typeof scalar === 'object') return; + const field = definition.fields?.find( + (item) => item.path === eventReference.path, + ); + if (!field || scalar === null) return; + if (typeof scalar !== field.type) { + errors.push( + `${path} must compare ${field.label} with a ${field.type} value.`, + ); + } + if (field.type !== 'number' && !['eq', 'ne'].includes(predicate.operator)) { + errors.push(`${path} must use equality comparison for ${field.label}.`); + } } function validateMechanicEvents(events: MechanicEvent[]) { - const errors: string[] = []; - for (const [index, event] of events.entries()) { - const definition = MECHANIC_EVENT_REGISTRY[event.kind]; - if (!definition?.strictFields) continue; - for (const field of definition.fields ?? []) { - const value = getPath(event, field.path); - const fieldPath = `Event ${index + 1} (${definition.label}) ${field.label}`; - if (value === undefined || value === null) { - if (field.required) errors.push(`${fieldPath} is required.`); - continue; - } - if (typeof value !== field.type || (field.type === 'number' && !finiteNumber(value))) { - errors.push(`${fieldPath} must be a ${field.type}.`); - continue; - } - if (field.type === 'number' && field.minimum !== undefined && (value as number) < field.minimum) { - errors.push(`${fieldPath} must be ${field.minimum} or greater.`); - } - } - const hasExtraHealth = getPath(event, 'values.hasExtraHealth'); - const currentExtraHealth = getPath(event, 'values.currentExtraHealth'); - if ( - event.kind === 'extra_health_changed' - && typeof hasExtraHealth === 'boolean' - && finiteNumber(currentExtraHealth) - && hasExtraHealth !== (currentExtraHealth > 0) - ) { - errors.push(`Event ${index + 1} (Extra Health changed) status must match its current Extra Health.`); - } - } - return errors; + const errors: string[] = []; + for (const [index, event] of events.entries()) { + const definition = MECHANIC_EVENT_REGISTRY[event.kind]; + if (!definition?.strictFields) continue; + for (const field of definition.fields ?? []) { + const value = getPath(event, field.path); + const fieldPath = `Event ${index + 1} (${definition.label}) ${field.label}`; + if (value === undefined || value === null) { + if (field.required) errors.push(`${fieldPath} is required.`); + continue; + } + if ( + typeof value !== field.type || + (field.type === 'number' && !finiteNumber(value)) + ) { + errors.push(`${fieldPath} must be a ${field.type}.`); + continue; + } + if ( + field.type === 'number' && + field.minimum !== undefined && + (value as number) < field.minimum + ) { + errors.push(`${fieldPath} must be ${field.minimum} or greater.`); + } + } + const hasExtraHealth = getPath(event, 'values.hasExtraHealth'); + const currentExtraHealth = getPath(event, 'values.currentExtraHealth'); + if ( + event.kind === 'extra_health_changed' && + typeof hasExtraHealth === 'boolean' && + finiteNumber(currentExtraHealth) && + hasExtraHealth !== currentExtraHealth > 0 + ) { + errors.push( + `Event ${index + 1} (Extra Health changed) status must match its current Extra Health.`, + ); + } + } + return errors; } function visitExpression( - expression: NumericExpression, - pools: Set, - inputs: Set, - errors: string[], - path: string + expression: NumericExpression, + pools: Set, + inputs: Set, + errors: string[], + path: string, ) { - if (typeof expression === 'number') { - if (!finiteNumber(expression)) errors.push(`${path} must be finite.`); - return; - } - if ('ref' in expression) { - validateReference(expression.ref, pools, inputs, errors, path); - return; - } - if (expression.op === 'floor') { - visitExpression(expression.value, pools, inputs, errors, `${path} value`); - return; - } - if (expression.op === 'clamp') { - visitExpression(expression.value, pools, inputs, errors, `${path} value`); - visitExpression(expression.min, pools, inputs, errors, `${path} minimum`); - visitExpression(expression.max, pools, inputs, errors, `${path} maximum`); - return; - } - visitExpression(expression.left, pools, inputs, errors, `${path} left operand`); - visitExpression(expression.right, pools, inputs, errors, `${path} right operand`); + if (typeof expression === 'number') { + if (!finiteNumber(expression)) errors.push(`${path} must be finite.`); + return; + } + if ('ref' in expression) { + validateReference(expression.ref, pools, inputs, errors, path); + return; + } + if (expression.op === 'floor') { + visitExpression(expression.value, pools, inputs, errors, `${path} value`); + return; + } + if (expression.op === 'clamp') { + visitExpression(expression.value, pools, inputs, errors, `${path} value`); + visitExpression(expression.min, pools, inputs, errors, `${path} minimum`); + visitExpression(expression.max, pools, inputs, errors, `${path} maximum`); + return; + } + visitExpression( + expression.left, + pools, + inputs, + errors, + `${path} left operand`, + ); + visitExpression( + expression.right, + pools, + inputs, + errors, + `${path} right operand`, + ); } function visitPredicate( - predicate: Predicate, - pools: Set, - inputs: Set, - errors: string[], - path: string + predicate: Predicate, + pools: Set, + inputs: Set, + errors: string[], + path: string, ) { - if (predicate.kind === 'true') return; - if (predicate.kind === 'exists') { - validateReference(predicate.value, pools, inputs, errors, path); - return; - } - if (predicate.kind === 'not') { - visitPredicate(predicate.predicate, pools, inputs, errors, `${path} NOT`); - return; - } - if (predicate.kind === 'all' || predicate.kind === 'any') { - if (!predicate.predicates.length) errors.push(`${path} ${predicate.kind.toUpperCase()} requires a predicate.`); - predicate.predicates.forEach((item, index) => - visitPredicate(item, pools, inputs, errors, `${path} ${index + 1}`)); - return; - } - if (typeof predicate.left === 'object' && predicate.left !== null) { - validateReference(predicate.left, pools, inputs, errors, `${path} left operand`); - } - if (typeof predicate.right === 'object' && predicate.right !== null) { - validateReference(predicate.right, pools, inputs, errors, `${path} right operand`); - } + if (predicate.kind === 'true') return; + if (predicate.kind === 'exists') { + validateReference(predicate.value, pools, inputs, errors, path); + return; + } + if (predicate.kind === 'not') { + visitPredicate(predicate.predicate, pools, inputs, errors, `${path} NOT`); + return; + } + if (predicate.kind === 'all' || predicate.kind === 'any') { + if (!predicate.predicates.length) + errors.push( + `${path} ${predicate.kind.toUpperCase()} requires a predicate.`, + ); + predicate.predicates.forEach((item, index) => + visitPredicate(item, pools, inputs, errors, `${path} ${index + 1}`), + ); + return; + } + if (typeof predicate.left === 'object' && predicate.left !== null) { + validateReference( + predicate.left, + pools, + inputs, + errors, + `${path} left operand`, + ); + } + if (typeof predicate.right === 'object' && predicate.right !== null) { + validateReference( + predicate.right, + pools, + inputs, + errors, + `${path} right operand`, + ); + } } function expressionUsesCombat(expression: NumericExpression): boolean { - if (typeof expression === 'number') return false; - if ('ref' in expression) return expression.ref.source === 'combat'; - if (expression.op === 'floor') return expressionUsesCombat(expression.value); - if (expression.op === 'clamp') { - return expressionUsesCombat(expression.value) - || expressionUsesCombat(expression.min) - || expressionUsesCombat(expression.max); - } - return expressionUsesCombat(expression.left) || expressionUsesCombat(expression.right); + if (typeof expression === 'number') return false; + if ('ref' in expression) return expression.ref.source === 'combat'; + if (expression.op === 'floor') return expressionUsesCombat(expression.value); + if (expression.op === 'clamp') { + return ( + expressionUsesCombat(expression.value) || + expressionUsesCombat(expression.min) || + expressionUsesCombat(expression.max) + ); + } + return ( + expressionUsesCombat(expression.left) || + expressionUsesCombat(expression.right) + ); } function expressionUsesEquipment(expression: NumericExpression): boolean { - if (typeof expression === 'number') return false; - if ('ref' in expression) return expression.ref.source === 'equipment'; - if (expression.op === 'floor') return expressionUsesEquipment(expression.value); - if (expression.op === 'clamp') { - return expressionUsesEquipment(expression.value) - || expressionUsesEquipment(expression.min) - || expressionUsesEquipment(expression.max); - } - return expressionUsesEquipment(expression.left) || expressionUsesEquipment(expression.right); + if (typeof expression === 'number') return false; + if ('ref' in expression) return expression.ref.source === 'equipment'; + if (expression.op === 'floor') + return expressionUsesEquipment(expression.value); + if (expression.op === 'clamp') { + return ( + expressionUsesEquipment(expression.value) || + expressionUsesEquipment(expression.min) || + expressionUsesEquipment(expression.max) + ); + } + return ( + expressionUsesEquipment(expression.left) || + expressionUsesEquipment(expression.right) + ); } function predicateUsesCombat(predicate: Predicate): boolean { - if (predicate.kind === 'true') return false; - if (predicate.kind === 'exists') return predicate.value.source === 'combat'; - if (predicate.kind === 'not') return predicateUsesCombat(predicate.predicate); - if (predicate.kind === 'all' || predicate.kind === 'any') { - return predicate.predicates.some(predicateUsesCombat); - } - return ( - (typeof predicate.left === 'object' && predicate.left?.source === 'combat') - || (typeof predicate.right === 'object' && predicate.right?.source === 'combat') - ); + if (predicate.kind === 'true') return false; + if (predicate.kind === 'exists') return predicate.value.source === 'combat'; + if (predicate.kind === 'not') return predicateUsesCombat(predicate.predicate); + if (predicate.kind === 'all' || predicate.kind === 'any') { + return predicate.predicates.some(predicateUsesCombat); + } + return ( + (typeof predicate.left === 'object' && + predicate.left?.source === 'combat') || + (typeof predicate.right === 'object' && + predicate.right?.source === 'combat') + ); } function predicateUsesEquipment(predicate: Predicate): boolean { - if (predicate.kind === 'true') return false; - if (predicate.kind === 'exists') return predicate.value.source === 'equipment'; - if (predicate.kind === 'not') return predicateUsesEquipment(predicate.predicate); - if (predicate.kind === 'all' || predicate.kind === 'any') { - return predicate.predicates.some(predicateUsesEquipment); - } - return ( - (typeof predicate.left === 'object' && predicate.left?.source === 'equipment') - || (typeof predicate.right === 'object' && predicate.right?.source === 'equipment') - ); + if (predicate.kind === 'true') return false; + if (predicate.kind === 'exists') + return predicate.value.source === 'equipment'; + if (predicate.kind === 'not') + return predicateUsesEquipment(predicate.predicate); + if (predicate.kind === 'all' || predicate.kind === 'any') { + return predicate.predicates.some(predicateUsesEquipment); + } + return ( + (typeof predicate.left === 'object' && + predicate.left?.source === 'equipment') || + (typeof predicate.right === 'object' && + predicate.right?.source === 'equipment') + ); } function programCombatRequirements(program: MechanicProgram) { - let required = false; - let weaponBaseDamage = false; - let equipment = false; - const attributeIds = new Set(); - const visit = (value: unknown) => { - if (!value || typeof value !== 'object') return; - if ( - 'source' in value - && value.source === 'attribute' - && 'attributeId' in value - && typeof value.attributeId === 'string' - ) { - attributeIds.add(value.attributeId); - } - for (const child of Object.values(value)) visit(child); - }; - visit(program); - for (const clause of program.clauses) { - if (clause.if && predicateUsesCombat(clause.if)) required = true; - if (clause.if && predicateUsesEquipment(clause.if)) equipment = true; - if (clause.weight && expressionUsesCombat(clause.weight)) required = true; - if (clause.weight && expressionUsesEquipment(clause.weight)) equipment = true; - if (clause.reducer.kind === 'derived_scalar' && expressionUsesCombat(clause.reducer.value)) required = true; - if (clause.reducer.kind === 'derived_scalar' && expressionUsesEquipment(clause.reducer.value)) equipment = true; - for (const mutation of clause.mutations) { - if ('value' in mutation && mutation.value !== undefined && mutation.value !== 'all') { - if (expressionUsesCombat(mutation.value)) required = true; - if (expressionUsesEquipment(mutation.value)) equipment = true; - } - } - for (const outcome of clause.outcomes ?? []) { - if (outcome.basis !== 'flat' || expressionUsesCombat(outcome.value)) required = true; - if (expressionUsesEquipment(outcome.value)) equipment = true; - if (outcome.basis === 'weapon_base_percent') weaponBaseDamage = true; - } - } - if (attributeIds.size) required = true; - return { required, weaponBaseDamage, equipment, attributeIds }; + let required = false; + let weaponBaseDamage = false; + let equipment = false; + const attributeIds = new Set(); + const visit = (value: unknown) => { + if (!value || typeof value !== 'object') return; + if ( + 'source' in value && + value.source === 'attribute' && + 'attributeId' in value && + typeof value.attributeId === 'string' + ) { + attributeIds.add(value.attributeId); + } + for (const child of Object.values(value)) visit(child); + }; + visit(program); + for (const clause of program.clauses) { + if (clause.if && predicateUsesCombat(clause.if)) required = true; + if (clause.if && predicateUsesEquipment(clause.if)) equipment = true; + if (clause.weight && expressionUsesCombat(clause.weight)) required = true; + if (clause.weight && expressionUsesEquipment(clause.weight)) + equipment = true; + if ( + clause.reducer.kind === 'derived_scalar' && + expressionUsesCombat(clause.reducer.value) + ) + required = true; + if ( + clause.reducer.kind === 'derived_scalar' && + expressionUsesEquipment(clause.reducer.value) + ) + equipment = true; + for (const mutation of clause.mutations) { + if ( + 'value' in mutation && + mutation.value !== undefined && + mutation.value !== 'all' + ) { + if (expressionUsesCombat(mutation.value)) required = true; + if (expressionUsesEquipment(mutation.value)) equipment = true; + } + } + for (const outcome of clause.outcomes ?? []) { + if (outcome.basis !== 'flat' || expressionUsesCombat(outcome.value)) + required = true; + if (expressionUsesEquipment(outcome.value)) equipment = true; + if (outcome.basis === 'weapon_base_percent') weaponBaseDamage = true; + } + } + if (attributeIds.size) required = true; + return { required, weaponBaseDamage, equipment, attributeIds }; } -export function validateMechanicProgram(program: MechanicProgram): ValidationResult { - const errors: string[] = []; - if (!program.id.trim()) errors.push('Program ID is required.'); - if (!program.name.trim()) errors.push('Program name is required.'); - const inputKeys = new Set(); - for (const [index, input] of (program.scenarioInputs ?? []).entries()) { - const path = `Scenario input ${index + 1}`; - if (!input.key.trim()) errors.push(`${path} requires a key.`); - if (inputKeys.has(input.key)) errors.push(`Scenario input "${input.key}" is duplicated.`); - inputKeys.add(input.key); - const required = (input.readiness ?? 'ready') === 'ready'; - if (input.kind === 'toggle') { - if ( - (required && (typeof input.defaultValue !== 'boolean' || typeof input.bestCaseValue !== 'boolean')) - || (input.defaultValue !== undefined && typeof input.defaultValue !== 'boolean') - || (input.bestCaseValue !== undefined && typeof input.bestCaseValue !== 'boolean') - ) { - errors.push(`${path} toggle values must be booleans.`); - } - } else { - if ( - (required && (!finiteNumber(input.defaultValue) || !finiteNumber(input.bestCaseValue))) - || (input.defaultValue !== undefined && !finiteNumber(input.defaultValue)) - || (input.bestCaseValue !== undefined && !finiteNumber(input.bestCaseValue)) - ) { - errors.push(`${path} values must be finite numbers.`); - } - if (input.min !== undefined && input.max !== undefined && input.min > input.max) { - errors.push(`${path} minimum cannot exceed its maximum.`); - } - for (const value of [input.defaultValue, input.bestCaseValue]) { - if (typeof value !== 'number') continue; - if (input.min !== undefined && value < input.min) errors.push(`${path} value is below its minimum.`); - if (input.max !== undefined && value > input.max) errors.push(`${path} value exceeds its maximum.`); - } - } - } - const poolKeys = new Set(); - for (const [index, pool] of (program.pools ?? []).entries()) { - const path = `Pool ${index + 1}`; - if (!pool.key.trim()) errors.push(`${path} requires a key.`); - if (poolKeys.has(pool.key)) errors.push(`Pool "${pool.key}" is duplicated.`); - poolKeys.add(pool.key); - if (!finiteNumber(pool.initial)) errors.push(`${path} initial value must be finite.`); - if (pool.min !== undefined && pool.max !== undefined && pool.min > pool.max) { - errors.push(`${path} minimum cannot exceed its maximum.`); - } - if (pool.min !== undefined && pool.initial < pool.min) errors.push(`${path} initial value is below its minimum.`); - if (pool.max !== undefined && pool.initial > pool.max) errors.push(`${path} initial value exceeds its maximum.`); - if (pool.bestCaseValue !== undefined) { - if (!finiteNumber(pool.bestCaseValue)) errors.push(`${path} best-case value must be finite.`); - if (pool.min !== undefined && pool.bestCaseValue < pool.min) errors.push(`${path} best-case value is below its minimum.`); - if (pool.max !== undefined && pool.bestCaseValue > pool.max) errors.push(`${path} best-case value exceeds its maximum.`); - } - if (pool.expiresAfterSeconds !== undefined && (!finiteNumber(pool.expiresAfterSeconds) || pool.expiresAfterSeconds <= 0)) { - errors.push(`${path} expiry must be greater than 0 seconds.`); - } - } - const clauseIds = new Set(); - const outcomeDefinitions = new Map(); - for (const [index, clause] of program.clauses.entries()) { - const path = `Clause ${index + 1}`; - if (!clause.id.trim()) errors.push(`${path} requires an ID.`); - if (clause.recipeKey !== undefined && !clause.recipeKey.trim()) { - errors.push(`${path} recipe key cannot be empty.`); - } - if (clauseIds.has(clause.id)) errors.push(`Clause "${clause.id}" is duplicated.`); - clauseIds.add(clause.id); - if (!(MECHANIC_EVENT_KINDS as readonly string[]).includes(clause.when)) { - errors.push(`${path} has an unknown event kind.`); - } - if (clause.if) visitPredicate(clause.if, poolKeys, inputKeys, errors, `${path} predicate`); - validateStrictEventReferences(clause.if, clause.when, errors, `${path} predicate`); - validateStrictEventPredicate(clause.if, clause.when, errors, `${path} predicate`); - if (clause.weight) visitExpression(clause.weight, poolKeys, inputKeys, errors, `${path} weight`); - validateStrictEventReferences(clause.weight, clause.when, errors, `${path} weight`); - if ( - clause.cooldownSeconds !== undefined - && (!finiteNumber(clause.cooldownSeconds) || clause.cooldownSeconds <= 0) - ) { - errors.push(`${path} cooldown must be greater than 0 seconds.`); - } - const reducer = clause.reducer; - if (reducer.kind === 'count' && (!Number.isInteger(reducer.threshold) || reducer.threshold <= 0)) { - errors.push(`${path} count threshold must be a positive integer.`); - } - if (reducer.kind === 'modulo' && (!Number.isInteger(reducer.every) || reducer.every <= 0)) { - errors.push(`${path} modulo interval must be a positive integer.`); - } - if (reducer.kind === 'distinct_count') { - if (!Number.isInteger(reducer.threshold) || reducer.threshold <= 0) { - errors.push(`${path} distinct-count threshold must be a positive integer.`); - } - if (!finiteNumber(reducer.windowSeconds) || reducer.windowSeconds <= 0) { - errors.push(`${path} distinct-count window must be greater than 0 seconds.`); - } - validateReference(reducer.distinctBy, poolKeys, inputKeys, errors, `${path} distinct-count value`); - } - if (reducer.kind === 'consecutive_count' && (!Number.isInteger(reducer.threshold) || reducer.threshold <= 0)) { - errors.push(`${path} consecutive-count threshold must be a positive integer.`); - } - if (reducer.kind === 'sliding_count') { - if (!Number.isInteger(reducer.threshold) || reducer.threshold <= 0) { - errors.push(`${path} sliding-count threshold must be a positive integer.`); - } - if (!finiteNumber(reducer.windowSeconds) || reducer.windowSeconds <= 0) { - errors.push(`${path} sliding window must be greater than 0 seconds.`); - } - } - if (reducer.kind === 'duration' && (!finiteNumber(reducer.seconds) || reducer.seconds <= 0)) { - errors.push(`${path} duration must be greater than 0 seconds.`); - } - if (reducer.kind === 'periodic') { - visitExpression( - reducer.intervalSeconds, - poolKeys, - inputKeys, - errors, - `${path} periodic interval` - ); - if ( - finiteNumber(reducer.intervalSeconds) - && reducer.intervalSeconds <= 0 - ) { - errors.push(`${path} periodic interval must be greater than 0 seconds.`); - } - } - if ('groupBy' in reducer && reducer.groupBy) { - validateReference(reducer.groupBy, poolKeys, inputKeys, errors, `${path} reducer group`); - } - if (reducer.kind === 'derived_scalar') { - visitExpression(reducer.value, poolKeys, inputKeys, errors, `${path} reducer value`); - } - validateStrictEventReferences(reducer, clause.when, errors, `${path} reducer`); - for (const [mutationIndex, mutation] of clause.mutations.entries()) { - if (mutation.kind === 'none') continue; - const mutationPath = `${path} mutation ${mutationIndex + 1}`; - if (!poolKeys.has(mutation.pool)) errors.push(`${mutationPath} references unknown pool "${mutation.pool}".`); - if ('value' in mutation && mutation.value !== undefined && mutation.value !== 'all') { - visitExpression(mutation.value, poolKeys, inputKeys, errors, `${mutationPath} value`); - } - if (mutation.kind === 'transfer') { - const definition = (program.pools ?? []).find((pool) => pool.key === mutation.pool); - if (definition && definition.scope !== 'target') { - errors.push(`${mutationPath} can only transfer a target-scoped pool.`); - } - } - } - validateStrictEventReferences(clause.mutations, clause.when, errors, `${path} mutations`); - for (const [outcomeIndex, outcome] of (clause.outcomes ?? []).entries()) { - const outcomePath = `${path} outcome ${outcomeIndex + 1}`; - if (!outcome.id.trim()) errors.push(`${outcomePath} requires an ID.`); - const existingOutcome = outcomeDefinitions.get(outcome.id); - if (existingOutcome && !mechanicDefinitionsEqual(existingOutcome, outcome)) { - errors.push(`Outcome "${outcome.id}" has conflicting definitions.`); - } else if (!existingOutcome) { - outcomeDefinitions.set(outcome.id, outcome); - } - if (outcome.kind === 'attribute_modifier' && !outcome.attributeId?.trim()) { - errors.push(`${outcomePath} requires an Attribute ID.`); - } - if (![ - 'self', - 'equipped_weapon', - 'inactive_weapon', - 'target', - 'allies', - 'self_and_allies', - 'assigned_ability', - 'all_skills' - ].includes(outcome.scope)) { - errors.push(`${outcomePath} has an unsupported scope.`); - } - if ( - outcome.kind === 'weapon_reload' - && outcome.scope !== 'equipped_weapon' - && outcome.scope !== 'inactive_weapon' - ) { - errors.push(`${outcomePath} weapon reload must target the equipped or inactive weapon.`); - } - if (outcome.scope === 'inactive_weapon' && outcome.kind !== 'weapon_reload') { - errors.push(`${outcomePath} can only target the inactive weapon with a weapon reload.`); - } - if (outcome.targeting !== undefined) { - if ( - outcome.targeting.kind !== 'within_radius' - || !['allies', 'enemies'].includes(outcome.targeting.population) - || !finiteNumber(outcome.targeting.radiusMeters) - || outcome.targeting.radiusMeters <= 0 - ) { - errors.push(`${outcomePath} has invalid radius targeting.`); - } else if ( - outcome.targeting.population === 'allies' - && outcome.scope !== 'allies' - && outcome.scope !== 'self_and_allies' - ) { - errors.push(`${outcomePath} ally radius targeting requires allies or self-and-allies scope.`); - } else if ( - outcome.targeting.population === 'enemies' - && outcome.scope !== 'target' - ) { - errors.push(`${outcomePath} enemy radius targeting requires target scope.`); - } - } - if (outcome.kind === 'weapon_reload' && finiteNumber(outcome.value) && outcome.value < 0) { - errors.push(`${outcomePath} weapon reload delay cannot be negative.`); - } - if ((outcome.kind === 'restoration' || outcome.kind === 'resource_cost') && outcome.resource === undefined) { - errors.push(`${outcomePath} requires a resource.`); - } - if ((outcome.kind === 'restoration' || outcome.kind === 'resource_cost') && outcome.resource !== undefined - && !['health', 'armor', 'ammo', 'signature_ability_charge'].includes(outcome.resource)) { - errors.push(`${outcomePath} has an unsupported resource.`); - } - if (outcome.resource !== undefined && outcome.kind !== 'restoration' && outcome.kind !== 'resource_cost') { - errors.push(`${outcomePath} can only set a resource for restoration or resource-cost outcomes.`); - } - const delivery = outcome.delivery ?? 'instant'; - if (!['instant', 'continuous'].includes(delivery)) { - errors.push(`${outcomePath} has an unsupported delivery mode.`); - } else if (delivery === 'continuous') { - if (outcome.kind !== 'restoration') { - errors.push(`${outcomePath} can only use continuous delivery for restoration.`); - } - if (!finiteNumber(outcome.durationSeconds) || (outcome.durationSeconds ?? 0) <= 0) { - errors.push(`${outcomePath} continuous delivery duration must be greater than 0 seconds.`); - } - } else if (outcome.durationSeconds !== undefined) { - errors.push(`${outcomePath} can only set a duration for continuous delivery.`); - } - const allowedBasis: Record = { - attribute_modifier: ['flat'], - direct_damage: ['flat', 'max_health_percent', 'current_health_percent', 'weapon_base_percent'], - restoration: ['flat', 'max_health_percent', 'current_health_percent'], - resource_cost: ['flat', 'max_health_percent', 'current_health_percent'], - weapon_reload: ['flat'], - marker: ['flat'] - }; - if (!allowedBasis[outcome.kind].includes(outcome.basis)) { - errors.push(`${outcomePath} cannot use basis "${outcome.basis}".`); - } - if (outcome.statePool && !poolKeys.has(outcome.statePool)) { - errors.push(`${outcomePath} references unknown pool "${outcome.statePool}".`); - } - if ( - outcome.minStacks !== undefined - && outcome.maxStacks !== undefined - && outcome.minStacks > outcome.maxStacks - ) { - errors.push(`${outcomePath} minimum stacks cannot exceed maximum stacks.`); - } - visitExpression(outcome.value, poolKeys, inputKeys, errors, `${outcomePath} value`); - } - validateStrictEventReferences(clause.outcomes, clause.when, errors, `${path} outcomes`); - } - return errors.length ? { ok: false, errors } : { ok: true, value: program }; +export function validateMechanicProgram( + program: MechanicProgram, +): ValidationResult { + const errors: string[] = []; + if (!program.id.trim()) errors.push('Program ID is required.'); + if (!program.name.trim()) errors.push('Program name is required.'); + const inputKeys = new Set(); + for (const [index, input] of (program.scenarioInputs ?? []).entries()) { + const path = `Scenario input ${index + 1}`; + if (!input.key.trim()) errors.push(`${path} requires a key.`); + if (inputKeys.has(input.key)) + errors.push(`Scenario input "${input.key}" is duplicated.`); + inputKeys.add(input.key); + const required = (input.readiness ?? 'ready') === 'ready'; + if (input.kind === 'toggle') { + if ( + (required && + (typeof input.defaultValue !== 'boolean' || + typeof input.bestCaseValue !== 'boolean')) || + (input.defaultValue !== undefined && + typeof input.defaultValue !== 'boolean') || + (input.bestCaseValue !== undefined && + typeof input.bestCaseValue !== 'boolean') + ) { + errors.push(`${path} toggle values must be booleans.`); + } + } else { + if ( + (required && + (!finiteNumber(input.defaultValue) || + !finiteNumber(input.bestCaseValue))) || + (input.defaultValue !== undefined && + !finiteNumber(input.defaultValue)) || + (input.bestCaseValue !== undefined && + !finiteNumber(input.bestCaseValue)) + ) { + errors.push(`${path} values must be finite numbers.`); + } + if ( + input.min !== undefined && + input.max !== undefined && + input.min > input.max + ) { + errors.push(`${path} minimum cannot exceed its maximum.`); + } + for (const value of [input.defaultValue, input.bestCaseValue]) { + if (typeof value !== 'number') continue; + if (input.min !== undefined && value < input.min) + errors.push(`${path} value is below its minimum.`); + if (input.max !== undefined && value > input.max) + errors.push(`${path} value exceeds its maximum.`); + } + } + } + const poolKeys = new Set(); + for (const [index, pool] of (program.pools ?? []).entries()) { + const path = `Pool ${index + 1}`; + if (!pool.key.trim()) errors.push(`${path} requires a key.`); + if (poolKeys.has(pool.key)) + errors.push(`Pool "${pool.key}" is duplicated.`); + poolKeys.add(pool.key); + if (!finiteNumber(pool.initial)) + errors.push(`${path} initial value must be finite.`); + if ( + pool.min !== undefined && + pool.max !== undefined && + pool.min > pool.max + ) { + errors.push(`${path} minimum cannot exceed its maximum.`); + } + if (pool.min !== undefined && pool.initial < pool.min) + errors.push(`${path} initial value is below its minimum.`); + if (pool.max !== undefined && pool.initial > pool.max) + errors.push(`${path} initial value exceeds its maximum.`); + if (pool.bestCaseValue !== undefined) { + if (!finiteNumber(pool.bestCaseValue)) + errors.push(`${path} best-case value must be finite.`); + if (pool.min !== undefined && pool.bestCaseValue < pool.min) + errors.push(`${path} best-case value is below its minimum.`); + if (pool.max !== undefined && pool.bestCaseValue > pool.max) + errors.push(`${path} best-case value exceeds its maximum.`); + } + if ( + pool.expiresAfterSeconds !== undefined && + (!finiteNumber(pool.expiresAfterSeconds) || pool.expiresAfterSeconds <= 0) + ) { + errors.push(`${path} expiry must be greater than 0 seconds.`); + } + } + const clauseIds = new Set(); + const outcomeDefinitions = new Map(); + for (const [index, clause] of program.clauses.entries()) { + const path = `Clause ${index + 1}`; + if (!clause.id.trim()) errors.push(`${path} requires an ID.`); + if (clause.recipeKey !== undefined && !clause.recipeKey.trim()) { + errors.push(`${path} recipe key cannot be empty.`); + } + if (clauseIds.has(clause.id)) + errors.push(`Clause "${clause.id}" is duplicated.`); + clauseIds.add(clause.id); + if (!(MECHANIC_EVENT_KINDS as readonly string[]).includes(clause.when)) { + errors.push(`${path} has an unknown event kind.`); + } + if (clause.if) + visitPredicate( + clause.if, + poolKeys, + inputKeys, + errors, + `${path} predicate`, + ); + validateStrictEventReferences( + clause.if, + clause.when, + errors, + `${path} predicate`, + ); + validateStrictEventPredicate( + clause.if, + clause.when, + errors, + `${path} predicate`, + ); + if (clause.weight) + visitExpression( + clause.weight, + poolKeys, + inputKeys, + errors, + `${path} weight`, + ); + validateStrictEventReferences( + clause.weight, + clause.when, + errors, + `${path} weight`, + ); + if ( + clause.cooldownSeconds !== undefined && + (!finiteNumber(clause.cooldownSeconds) || clause.cooldownSeconds <= 0) + ) { + errors.push(`${path} cooldown must be greater than 0 seconds.`); + } + const reducer = clause.reducer; + if ( + reducer.kind === 'count' && + (!Number.isInteger(reducer.threshold) || reducer.threshold <= 0) + ) { + errors.push(`${path} count threshold must be a positive integer.`); + } + if ( + reducer.kind === 'modulo' && + (!Number.isInteger(reducer.every) || reducer.every <= 0) + ) { + errors.push(`${path} modulo interval must be a positive integer.`); + } + if (reducer.kind === 'distinct_count') { + if (!Number.isInteger(reducer.threshold) || reducer.threshold <= 0) { + errors.push( + `${path} distinct-count threshold must be a positive integer.`, + ); + } + if (!finiteNumber(reducer.windowSeconds) || reducer.windowSeconds <= 0) { + errors.push( + `${path} distinct-count window must be greater than 0 seconds.`, + ); + } + validateReference( + reducer.distinctBy, + poolKeys, + inputKeys, + errors, + `${path} distinct-count value`, + ); + } + if ( + reducer.kind === 'consecutive_count' && + (!Number.isInteger(reducer.threshold) || reducer.threshold <= 0) + ) { + errors.push( + `${path} consecutive-count threshold must be a positive integer.`, + ); + } + if (reducer.kind === 'sliding_count') { + if (!Number.isInteger(reducer.threshold) || reducer.threshold <= 0) { + errors.push( + `${path} sliding-count threshold must be a positive integer.`, + ); + } + if (!finiteNumber(reducer.windowSeconds) || reducer.windowSeconds <= 0) { + errors.push(`${path} sliding window must be greater than 0 seconds.`); + } + } + if ( + reducer.kind === 'duration' && + (!finiteNumber(reducer.seconds) || reducer.seconds <= 0) + ) { + errors.push(`${path} duration must be greater than 0 seconds.`); + } + if (reducer.kind === 'periodic') { + visitExpression( + reducer.intervalSeconds, + poolKeys, + inputKeys, + errors, + `${path} periodic interval`, + ); + if ( + finiteNumber(reducer.intervalSeconds) && + reducer.intervalSeconds <= 0 + ) { + errors.push( + `${path} periodic interval must be greater than 0 seconds.`, + ); + } + } + if ('groupBy' in reducer && reducer.groupBy) { + validateReference( + reducer.groupBy, + poolKeys, + inputKeys, + errors, + `${path} reducer group`, + ); + } + if (reducer.kind === 'derived_scalar') { + visitExpression( + reducer.value, + poolKeys, + inputKeys, + errors, + `${path} reducer value`, + ); + } + validateStrictEventReferences( + reducer, + clause.when, + errors, + `${path} reducer`, + ); + for (const [mutationIndex, mutation] of clause.mutations.entries()) { + if (mutation.kind === 'none') continue; + const mutationPath = `${path} mutation ${mutationIndex + 1}`; + if (!poolKeys.has(mutation.pool)) + errors.push( + `${mutationPath} references unknown pool "${mutation.pool}".`, + ); + if ( + 'value' in mutation && + mutation.value !== undefined && + mutation.value !== 'all' + ) { + visitExpression( + mutation.value, + poolKeys, + inputKeys, + errors, + `${mutationPath} value`, + ); + } + if (mutation.kind === 'transfer') { + const definition = (program.pools ?? []).find( + (pool) => pool.key === mutation.pool, + ); + if (definition && definition.scope !== 'target') { + errors.push( + `${mutationPath} can only transfer a target-scoped pool.`, + ); + } + } + } + validateStrictEventReferences( + clause.mutations, + clause.when, + errors, + `${path} mutations`, + ); + for (const [outcomeIndex, outcome] of (clause.outcomes ?? []).entries()) { + const outcomePath = `${path} outcome ${outcomeIndex + 1}`; + if (!outcome.id.trim()) errors.push(`${outcomePath} requires an ID.`); + const existingOutcome = outcomeDefinitions.get(outcome.id); + if ( + existingOutcome && + !mechanicDefinitionsEqual(existingOutcome, outcome) + ) { + errors.push(`Outcome "${outcome.id}" has conflicting definitions.`); + } else if (!existingOutcome) { + outcomeDefinitions.set(outcome.id, outcome); + } + if ( + outcome.kind === 'attribute_modifier' && + !outcome.attributeId?.trim() + ) { + errors.push(`${outcomePath} requires an Attribute ID.`); + } + if ( + ![ + 'self', + 'equipped_weapon', + 'inactive_weapon', + 'target', + 'allies', + 'self_and_allies', + 'assigned_ability', + 'all_skills', + ].includes(outcome.scope) + ) { + errors.push(`${outcomePath} has an unsupported scope.`); + } + if ( + outcome.kind === 'weapon_reload' && + outcome.scope !== 'equipped_weapon' && + outcome.scope !== 'inactive_weapon' + ) { + errors.push( + `${outcomePath} weapon reload must target the equipped or inactive weapon.`, + ); + } + if ( + outcome.scope === 'inactive_weapon' && + outcome.kind !== 'weapon_reload' + ) { + errors.push( + `${outcomePath} can only target the inactive weapon with a weapon reload.`, + ); + } + if (outcome.targeting !== undefined) { + if ( + outcome.targeting.kind !== 'within_radius' || + !['allies', 'enemies'].includes(outcome.targeting.population) || + !finiteNumber(outcome.targeting.radiusMeters) || + outcome.targeting.radiusMeters <= 0 + ) { + errors.push(`${outcomePath} has invalid radius targeting.`); + } else if ( + outcome.targeting.population === 'allies' && + outcome.scope !== 'allies' && + outcome.scope !== 'self_and_allies' + ) { + errors.push( + `${outcomePath} ally radius targeting requires allies or self-and-allies scope.`, + ); + } else if ( + outcome.targeting.population === 'enemies' && + outcome.scope !== 'target' + ) { + errors.push( + `${outcomePath} enemy radius targeting requires target scope.`, + ); + } + } + if ( + outcome.kind === 'weapon_reload' && + finiteNumber(outcome.value) && + outcome.value < 0 + ) { + errors.push(`${outcomePath} weapon reload delay cannot be negative.`); + } + if ( + (outcome.kind === 'restoration' || outcome.kind === 'resource_cost') && + outcome.resource === undefined + ) { + errors.push(`${outcomePath} requires a resource.`); + } + if ( + (outcome.kind === 'restoration' || outcome.kind === 'resource_cost') && + outcome.resource !== undefined && + !['health', 'armor', 'ammo', 'signature_ability_charge'].includes( + outcome.resource, + ) + ) { + errors.push(`${outcomePath} has an unsupported resource.`); + } + if ( + outcome.resource !== undefined && + outcome.kind !== 'restoration' && + outcome.kind !== 'resource_cost' + ) { + errors.push( + `${outcomePath} can only set a resource for restoration or resource-cost outcomes.`, + ); + } + const delivery = outcome.delivery ?? 'instant'; + if (!['instant', 'continuous'].includes(delivery)) { + errors.push(`${outcomePath} has an unsupported delivery mode.`); + } else if (delivery === 'continuous') { + if (outcome.kind !== 'restoration') { + errors.push( + `${outcomePath} can only use continuous delivery for restoration.`, + ); + } + if ( + !finiteNumber(outcome.durationSeconds) || + (outcome.durationSeconds ?? 0) <= 0 + ) { + errors.push( + `${outcomePath} continuous delivery duration must be greater than 0 seconds.`, + ); + } + } else if (outcome.durationSeconds !== undefined) { + errors.push( + `${outcomePath} can only set a duration for continuous delivery.`, + ); + } + const allowedBasis: Record< + MechanicOutcome['kind'], + readonly OutcomeBasis[] + > = { + attribute_modifier: ['flat'], + direct_damage: [ + 'flat', + 'max_health_percent', + 'current_health_percent', + 'weapon_base_percent', + ], + restoration: ['flat', 'max_health_percent', 'current_health_percent'], + resource_cost: ['flat', 'max_health_percent', 'current_health_percent'], + weapon_reload: ['flat'], + marker: ['flat'], + }; + if (!allowedBasis[outcome.kind].includes(outcome.basis)) { + errors.push(`${outcomePath} cannot use basis "${outcome.basis}".`); + } + if (outcome.statePool && !poolKeys.has(outcome.statePool)) { + errors.push( + `${outcomePath} references unknown pool "${outcome.statePool}".`, + ); + } + if ( + outcome.minStacks !== undefined && + outcome.maxStacks !== undefined && + outcome.minStacks > outcome.maxStacks + ) { + errors.push( + `${outcomePath} minimum stacks cannot exceed maximum stacks.`, + ); + } + visitExpression( + outcome.value, + poolKeys, + inputKeys, + errors, + `${outcomePath} value`, + ); + } + validateStrictEventReferences( + clause.outcomes, + clause.when, + errors, + `${path} outcomes`, + ); + } + return errors.length ? { ok: false, errors } : { ok: true, value: program }; } export function evaluateMechanicProgram( - program: MechanicProgram, - events: MechanicEvent[], - scenario: MechanicScenario, - combatContext?: MechanicCombatContext, - equipmentContext?: MechanicEquipmentContext + program: MechanicProgram, + events: MechanicEvent[], + scenario: MechanicScenario, + combatContext?: MechanicCombatContext, + equipmentContext?: MechanicEquipmentContext, ): MechanicEvaluation { - const validation = validateMechanicProgram(program); - const scenarioErrors: string[] = []; - scenarioErrors.push(...validateMechanicEvents(events)); - if (!finiteNumber(scenario.encounterSeconds) || scenario.encounterSeconds <= 0) { - scenarioErrors.push('Encounter seconds must be greater than 0.'); - } - if (scenario.mode === 'custom') { - const definitions = new Map((program.scenarioInputs ?? []).map((input) => [input.key, input])); - for (const [key, value] of Object.entries(scenario.inputs ?? {})) { - const definition = definitions.get(key); - if (!definition) { - scenarioErrors.push(`Custom scenario contains unknown input "${key}".`); - continue; - } - if (definition.kind === 'toggle') { - if (typeof value !== 'boolean') scenarioErrors.push(`Scenario input "${key}" must be a boolean.`); - continue; - } - if (!finiteNumber(value)) { - scenarioErrors.push(`Scenario input "${key}" must be a finite number.`); - continue; - } - if (definition.min !== undefined && value < definition.min) { - scenarioErrors.push(`Scenario input "${key}" is below its minimum.`); - } - if (definition.max !== undefined && value > definition.max) { - scenarioErrors.push(`Scenario input "${key}" exceeds its maximum.`); - } - } - } - const combatRequirements = programCombatRequirements(program); - if (combatRequirements.required && !combatContext) { - scenarioErrors.push('This mechanic requires authoritative combat context.'); - } - if (combatContext) { - if (!finiteNumber(combatContext.maxHealth) || combatContext.maxHealth <= 0) { - scenarioErrors.push('Combat maximum Health must be greater than 0.'); - } - if ( - !finiteNumber(combatContext.currentHealth) - || combatContext.currentHealth < 0 - || combatContext.currentHealth > combatContext.maxHealth - ) { - scenarioErrors.push('Combat current Health must be between 0 and maximum Health.'); - } - if ( - combatContext.weaponBaseDamage !== undefined - && (!finiteNumber(combatContext.weaponBaseDamage) || combatContext.weaponBaseDamage < 0) - ) { - scenarioErrors.push('Combat weapon base damage must be 0 or greater.'); - } - if (combatRequirements.weaponBaseDamage && combatContext.weaponBaseDamage === undefined) { - scenarioErrors.push('This mechanic requires authoritative weapon base damage.'); - } - for (const attributeId of combatRequirements.attributeIds) { - if (!finiteNumber(combatContext.attributes?.[attributeId])) { - scenarioErrors.push(`This mechanic requires authoritative Attribute ${attributeId}.`); - } - } - } - if (combatRequirements.equipment && !equipmentContext) { - scenarioErrors.push('This mechanic requires authoritative equipment context.'); - } - if ( - equipmentContext?.protocolTalentUpgrades !== undefined - && ( - !Number.isInteger(equipmentContext.protocolTalentUpgrades) - || equipmentContext.protocolTalentUpgrades < 0 - || equipmentContext.protocolTalentUpgrades > 5 - ) - ) { - scenarioErrors.push('Protocol talent upgrades must be an integer from 0 to 5.'); - } - if (!validation.ok || scenarioErrors.length) { - return invalidEvaluation(program, scenario, [ - ...(validation.ok ? [] : validation.errors), - ...scenarioErrors - ]); - } - const resolvedScenario = scenarioValues(program, scenario); - const inputs = resolvedScenario.values; - const combat = copyCombat(combatContext ?? null); - const equipment = equipmentContext ? { - ...equipmentContext, - values: equipmentContext.values ? { ...equipmentContext.values } : undefined - } : null; - const poolDefinitions = new Map((program.pools ?? []).map((pool) => [pool.key, pool])); - const pools = initialPools(poolDefinitions, scenario.mode); - const reducerStates = new Map(); - const cooldowns = new Map(); - const emissions: MechanicEmission[] = []; - const outcomes: MechanicOutcomeEmission[] = []; - const continuousDeliveries: ContinuousDelivery[] = []; - if (scenario.mode === 'maximum') { - for (const clause of program.clauses) { - if ( - clause.when === 'tick' - && events.some((event) => event.kind === 'tick' && Math.abs(event.at) <= EPSILON) - ) continue; - const event: MechanicEvent = { - kind: clause.when, - at: 0, - actorId: AGENT_ENTITY, - targetId: DEFAULT_TARGET_ENTITY - }; - const context: EvaluationContext = { - event, - scenario: inputs, - reducerValue: 1, - pools, - poolDefinitions, - combat, - preEventCombat: copyCombat(combat), - equipment - }; - if (!predicateMatches(clause.if, context)) continue; - const before = new Map((clause.outcomes ?? []) - .map((outcome) => outcome.statePool) - .filter((pool): pool is string => Boolean(pool)) - .map((pool) => [pool, stateValue(pool, undefined, context)])); - appendScheduledOutcomes(outcomes, emitOutcomes({ - ...clause, - outcomes: (clause.outcomes ?? []).filter((outcome) => outcome.phase === 'while_active') - }, 'after', 0, 1, before, context, continuousDeliveries), 0, scenario.encounterSeconds); - } - } - const ordered = events - .map((event, index) => ({ event, index })) - .filter(({ event }) => finiteNumber(event.at) && event.at >= 0 && event.at <= scenario.encounterSeconds) - .sort((left, right) => left.event.at - right.event.at || left.index - right.index) - .map(({ event }) => event); + const validation = validateMechanicProgram(program); + const scenarioErrors: string[] = []; + scenarioErrors.push(...validateMechanicEvents(events)); + if ( + !finiteNumber(scenario.encounterSeconds) || + scenario.encounterSeconds <= 0 + ) { + scenarioErrors.push('Encounter seconds must be greater than 0.'); + } + if (scenario.mode === 'custom') { + const definitions = new Map( + (program.scenarioInputs ?? []).map((input) => [input.key, input]), + ); + for (const [key, value] of Object.entries(scenario.inputs ?? {})) { + const definition = definitions.get(key); + if (!definition) { + scenarioErrors.push(`Custom scenario contains unknown input "${key}".`); + continue; + } + if (definition.kind === 'toggle') { + if (typeof value !== 'boolean') + scenarioErrors.push(`Scenario input "${key}" must be a boolean.`); + continue; + } + if (!finiteNumber(value)) { + scenarioErrors.push(`Scenario input "${key}" must be a finite number.`); + continue; + } + if (definition.min !== undefined && value < definition.min) { + scenarioErrors.push(`Scenario input "${key}" is below its minimum.`); + } + if (definition.max !== undefined && value > definition.max) { + scenarioErrors.push(`Scenario input "${key}" exceeds its maximum.`); + } + } + } + const combatRequirements = programCombatRequirements(program); + if (combatRequirements.required && !combatContext) { + scenarioErrors.push('This mechanic requires authoritative combat context.'); + } + if (combatContext) { + if ( + !finiteNumber(combatContext.maxHealth) || + combatContext.maxHealth <= 0 + ) { + scenarioErrors.push('Combat maximum Health must be greater than 0.'); + } + if ( + !finiteNumber(combatContext.currentHealth) || + combatContext.currentHealth < 0 || + combatContext.currentHealth > combatContext.maxHealth + ) { + scenarioErrors.push( + 'Combat current Health must be between 0 and maximum Health.', + ); + } + if ( + combatContext.weaponBaseDamage !== undefined && + (!finiteNumber(combatContext.weaponBaseDamage) || + combatContext.weaponBaseDamage < 0) + ) { + scenarioErrors.push('Combat weapon base damage must be 0 or greater.'); + } + if ( + combatRequirements.weaponBaseDamage && + combatContext.weaponBaseDamage === undefined + ) { + scenarioErrors.push( + 'This mechanic requires authoritative weapon base damage.', + ); + } + for (const attributeId of combatRequirements.attributeIds) { + if (!finiteNumber(combatContext.attributes?.[attributeId])) { + scenarioErrors.push( + `This mechanic requires authoritative Attribute ${attributeId}.`, + ); + } + } + } + if (combatRequirements.equipment && !equipmentContext) { + scenarioErrors.push( + 'This mechanic requires authoritative equipment context.', + ); + } + if ( + equipmentContext?.protocolTalentUpgrades !== undefined && + (!Number.isInteger(equipmentContext.protocolTalentUpgrades) || + equipmentContext.protocolTalentUpgrades < 0 || + equipmentContext.protocolTalentUpgrades > 5) + ) { + scenarioErrors.push( + 'Protocol talent upgrades must be an integer from 0 to 5.', + ); + } + if (!validation.ok || scenarioErrors.length) { + return invalidEvaluation(program, scenario, [ + ...(validation.ok ? [] : validation.errors), + ...scenarioErrors, + ]); + } + const resolvedScenario = scenarioValues(program, scenario); + const inputs = resolvedScenario.values; + const combat = copyCombat(combatContext ?? null); + const equipment = equipmentContext + ? { + ...equipmentContext, + values: equipmentContext.values + ? { ...equipmentContext.values } + : undefined, + } + : null; + const poolDefinitions = new Map( + (program.pools ?? []).map((pool) => [pool.key, pool]), + ); + const pools = initialPools(poolDefinitions, scenario.mode); + const reducerStates = new Map(); + const cooldowns = new Map(); + const emissions: MechanicEmission[] = []; + const outcomes: MechanicOutcomeEmission[] = []; + const continuousDeliveries: ContinuousDelivery[] = []; + if (scenario.mode === 'maximum') { + for (const clause of program.clauses) { + if ( + clause.when === 'tick' && + events.some( + (event) => event.kind === 'tick' && Math.abs(event.at) <= EPSILON, + ) + ) + continue; + const event: MechanicEvent = { + kind: clause.when, + at: 0, + actorId: AGENT_ENTITY, + targetId: DEFAULT_TARGET_ENTITY, + }; + const context: EvaluationContext = { + event, + scenario: inputs, + reducerValue: 1, + pools, + poolDefinitions, + combat, + preEventCombat: copyCombat(combat), + equipment, + }; + if (!predicateMatches(clause.if, context)) continue; + const before = new Map( + (clause.outcomes ?? []) + .map((outcome) => outcome.statePool) + .filter((pool): pool is string => Boolean(pool)) + .map((pool) => [pool, stateValue(pool, undefined, context)]), + ); + appendScheduledOutcomes( + outcomes, + emitOutcomes( + { + ...clause, + outcomes: (clause.outcomes ?? []).filter( + (outcome) => outcome.phase === 'while_active', + ), + }, + 'after', + 0, + 1, + before, + context, + continuousDeliveries, + ), + 0, + scenario.encounterSeconds, + ); + } + } + const ordered = events + .map((event, index) => ({ event, index })) + .filter( + ({ event }) => + finiteNumber(event.at) && + event.at >= 0 && + event.at <= scenario.encounterSeconds, + ) + .sort( + (left, right) => + left.event.at - right.event.at || left.index - right.index, + ) + .map(({ event }) => event); - let previousWeaponHitTargetId: string | null = null; - for (const rawEvent of ordered) { - const event: MechanicEvent = rawEvent.kind === 'weapon_hit' && rawEvent.targetId - ? { - ...rawEvent, - values: { - ...(rawEvent.values ?? {}), - ...(previousWeaponHitTargetId === null - ? {} - : { previousTargetId: previousWeaponHitTargetId }) - } - } - : rawEvent; - outcomes.push(...expirePools( - event.at, - program.clauses, - inputs, - pools, - poolDefinitions, - combat, - equipment, - continuousDeliveries - )); - outcomes.push(...advanceContinuousDeliveries(event.at, continuousDeliveries, combat)); - const preEventCombat = copyCombat(combat); - for (const clause of program.clauses) { - if (clause.when !== event.kind) continue; - const baseContext: EvaluationContext = { - event, - scenario: inputs, - reducerValue: 0, - pools, - poolDefinitions, - combat, - preEventCombat, - equipment - }; - const matched = predicateMatches(clause.if, baseContext); - const group = reducerGroup(clause.reducer, baseContext); - const stateKey = `${clause.id}\u0000${group}`; - if (event.at + EPSILON < (cooldowns.get(stateKey) ?? 0)) continue; - const reducerState = reducerStates.get(stateKey) ?? freshReducerState(); - reducerStates.set(stateKey, reducerState); - const reduced = reduceEvent(clause.reducer, matched, event, baseContext, reducerState); - for (const reduction of reduced) { - if (reduction.at + EPSILON < (cooldowns.get(stateKey) ?? 0)) continue; - const context = { ...baseContext, reducerValue: reduction.value }; - const weight = clause.weight ? clamp(numericValue(clause.weight, context), 0, 1) : 1; - const relevantPools = new Set((clause.outcomes ?? []) - .map((outcome) => outcome.statePool) - .filter((pool): pool is string => Boolean(pool))); - const before = new Map([...relevantPools].map((pool) => [pool, stateValue(pool, undefined, context)])); - appendScheduledOutcomes(outcomes, emitOutcomes( - clause, - 'before', - reduction.at, - weight, - before, - context, - continuousDeliveries - ), reduction.at, scenario.encounterSeconds); - const mutationRecords = clause.mutations.map((mutation) => - applyMutation(mutation, reduction.at, context, weight)); - emissions.push({ - clauseId: clause.id, - at: reduction.at, - weight, - reducerValue: reduction.value, - mutations: mutationRecords - }); - if (clause.cooldownSeconds) { - cooldowns.set(stateKey, reduction.at + clause.cooldownSeconds); - } - appendScheduledOutcomes(outcomes, emitOutcomes( - clause, - 'after', - reduction.at, - weight, - before, - context, - continuousDeliveries - ), reduction.at, scenario.encounterSeconds); - } - } - if (event.kind === 'weapon_hit' && event.targetId) { - previousWeaponHitTargetId = event.targetId; - } - } - outcomes.push(...expirePools( - scenario.encounterSeconds, - program.clauses, - inputs, - pools, - poolDefinitions, - combat, - equipment, - continuousDeliveries - )); - outcomes.push(...advanceContinuousDeliveries( - scenario.encounterSeconds, - continuousDeliveries, - combat - )); - return { - status: 'OK', - errors: [], - programId: program.id, - mode: scenario.mode, - scenarioInputs: inputs, - readiness: { - status: resolvedScenario.missing.length ? 'REQUIRES_ASSUMPTIONS' : 'READY', - missingInputs: resolvedScenario.missing - }, - emissions, - outcomes, - finalState: [...pools.values()].sort((left, right) => - left.pool.localeCompare(right.pool) || left.entityId.localeCompare(right.entityId)), - finalCombat: copyCombat(combat), - equipment, - estimatedActivationCount: emissions.reduce((total, emission) => total + emission.weight, 0), - script: formatMechanicProgram(program) - }; + let previousWeaponHitTargetId: string | null = null; + for (const rawEvent of ordered) { + const event: MechanicEvent = + rawEvent.kind === 'weapon_hit' && rawEvent.targetId + ? { + ...rawEvent, + values: { + ...(rawEvent.values ?? {}), + ...(previousWeaponHitTargetId === null + ? {} + : { previousTargetId: previousWeaponHitTargetId }), + }, + } + : rawEvent; + outcomes.push( + ...expirePools( + event.at, + program.clauses, + inputs, + pools, + poolDefinitions, + combat, + equipment, + continuousDeliveries, + ), + ); + outcomes.push( + ...advanceContinuousDeliveries(event.at, continuousDeliveries, combat), + ); + const preEventCombat = copyCombat(combat); + for (const clause of program.clauses) { + if (clause.when !== event.kind) continue; + const baseContext: EvaluationContext = { + event, + scenario: inputs, + reducerValue: 0, + pools, + poolDefinitions, + combat, + preEventCombat, + equipment, + }; + const matched = predicateMatches(clause.if, baseContext); + const group = reducerGroup(clause.reducer, baseContext); + const stateKey = `${clause.id}\u0000${group}`; + if (event.at + EPSILON < (cooldowns.get(stateKey) ?? 0)) continue; + const reducerState = reducerStates.get(stateKey) ?? freshReducerState(); + reducerStates.set(stateKey, reducerState); + const reduced = reduceEvent( + clause.reducer, + matched, + event, + baseContext, + reducerState, + ); + for (const reduction of reduced) { + if (reduction.at + EPSILON < (cooldowns.get(stateKey) ?? 0)) continue; + const context = { ...baseContext, reducerValue: reduction.value }; + const weight = clause.weight + ? clamp(numericValue(clause.weight, context), 0, 1) + : 1; + const relevantPools = new Set( + (clause.outcomes ?? []) + .map((outcome) => outcome.statePool) + .filter((pool): pool is string => Boolean(pool)), + ); + const before = new Map( + [...relevantPools].map((pool) => [ + pool, + stateValue(pool, undefined, context), + ]), + ); + appendScheduledOutcomes( + outcomes, + emitOutcomes( + clause, + 'before', + reduction.at, + weight, + before, + context, + continuousDeliveries, + ), + reduction.at, + scenario.encounterSeconds, + ); + const mutationRecords = clause.mutations.map((mutation) => + applyMutation(mutation, reduction.at, context, weight), + ); + emissions.push({ + clauseId: clause.id, + at: reduction.at, + weight, + reducerValue: reduction.value, + mutations: mutationRecords, + }); + if (clause.cooldownSeconds) { + cooldowns.set(stateKey, reduction.at + clause.cooldownSeconds); + } + appendScheduledOutcomes( + outcomes, + emitOutcomes( + clause, + 'after', + reduction.at, + weight, + before, + context, + continuousDeliveries, + ), + reduction.at, + scenario.encounterSeconds, + ); + } + } + if (event.kind === 'weapon_hit' && event.targetId) { + previousWeaponHitTargetId = event.targetId; + } + } + outcomes.push( + ...expirePools( + scenario.encounterSeconds, + program.clauses, + inputs, + pools, + poolDefinitions, + combat, + equipment, + continuousDeliveries, + ), + ); + outcomes.push( + ...advanceContinuousDeliveries( + scenario.encounterSeconds, + continuousDeliveries, + combat, + ), + ); + return { + status: 'OK', + errors: [], + programId: program.id, + mode: scenario.mode, + scenarioInputs: inputs, + readiness: { + status: resolvedScenario.missing.length + ? 'REQUIRES_ASSUMPTIONS' + : 'READY', + missingInputs: resolvedScenario.missing, + }, + emissions, + outcomes, + finalState: [...pools.values()].sort( + (left, right) => + left.pool.localeCompare(right.pool) || + left.entityId.localeCompare(right.entityId), + ), + finalCombat: copyCombat(combat), + equipment, + estimatedActivationCount: emissions.reduce( + (total, emission) => total + emission.weight, + 0, + ), + script: formatMechanicProgram(program), + }; } function formatEntity(selector: EntitySelector | undefined) { - if (!selector) return ''; - switch (selector.kind) { - case 'agent': - return ' FOR AGENT'; - case 'event_actor': - return ' FOR EVENT ACTOR'; - case 'event_target': - return ' FOR EVENT TARGET'; - case 'event_field': - return ` FOR EVENT.${selector.path}`; - } + if (!selector) return ''; + switch (selector.kind) { + case 'agent': + return ' FOR AGENT'; + case 'event_actor': + return ' FOR EVENT ACTOR'; + case 'event_target': + return ' FOR EVENT TARGET'; + case 'event_field': + return ` FOR EVENT.${selector.path}`; + } } function formatReference(reference: ValueReference): string { - switch (reference.source) { - case 'event': - return `EVENT.${reference.path}`; - case 'scenario': - return `SCENARIO.${reference.key}`; - case 'combat': - return `${reference.timing === 'pre_event' ? 'PRE-EVENT ' : ''}COMBAT.${reference.path}`; - case 'attribute': - return `ATTRIBUTE[${reference.attributeId}]`; - case 'equipment': - return `EQUIPMENT.${reference.path}`; - case 'reducer': - return 'REDUCER VALUE'; - case 'pool': - return `${reference.pool}${formatEntity(reference.entity)}`; - } + switch (reference.source) { + case 'event': + return `EVENT.${reference.path}`; + case 'scenario': + return `SCENARIO.${reference.key}`; + case 'combat': + return `${reference.timing === 'pre_event' ? 'PRE-EVENT ' : ''}COMBAT.${reference.path}`; + case 'attribute': + return `ATTRIBUTE[${reference.attributeId}]`; + case 'equipment': + return `EQUIPMENT.${reference.path}`; + case 'reducer': + return 'REDUCER VALUE'; + case 'pool': + return `${reference.pool}${formatEntity(reference.entity)}`; + } } function formatExpression(expression: NumericExpression): string { - if (typeof expression === 'number') return formatNumber(expression); - if ('ref' in expression) return formatReference(expression.ref); - if (expression.op === 'floor') return `FLOOR(${formatExpression(expression.value)})`; - if (expression.op === 'clamp') { - return `CLAMP(${formatExpression(expression.value)}, ${formatExpression(expression.min)}, ${formatExpression(expression.max)})`; - } - const operators = { - add: '+', - subtract: '-', - multiply: '*', - divide: '/', - min: 'MIN', - max: 'MAX' - } as const; - if (expression.op === 'min' || expression.op === 'max') { - return `${operators[expression.op]}(${formatExpression(expression.left)}, ${formatExpression(expression.right)})`; - } - return `(${formatExpression(expression.left)} ${operators[expression.op]} ${formatExpression(expression.right)})`; + if (typeof expression === 'number') return formatNumber(expression); + if ('ref' in expression) return formatReference(expression.ref); + if (expression.op === 'floor') + return `FLOOR(${formatExpression(expression.value)})`; + if (expression.op === 'clamp') { + return `CLAMP(${formatExpression(expression.value)}, ${formatExpression(expression.min)}, ${formatExpression(expression.max)})`; + } + const operators = { + add: '+', + subtract: '-', + multiply: '*', + divide: '/', + min: 'MIN', + max: 'MAX', + } as const; + if (expression.op === 'min' || expression.op === 'max') { + return `${operators[expression.op]}(${formatExpression(expression.left)}, ${formatExpression(expression.right)})`; + } + return `(${formatExpression(expression.left)} ${operators[expression.op]} ${formatExpression(expression.right)})`; } function formatOperand(operand: ValueReference | Scalar): string { - if (typeof operand === 'object' && operand !== null) return formatReference(operand); - if (typeof operand === 'string') return JSON.stringify(operand); - return String(operand); + if (typeof operand === 'object' && operand !== null) + return formatReference(operand); + if (typeof operand === 'string') return JSON.stringify(operand); + return String(operand); } function formatPredicate(predicate: Predicate): string { - if (predicate.kind === 'true') return 'TRUE'; - if (predicate.kind === 'exists') return `EXISTS ${formatReference(predicate.value)}`; - if (predicate.kind === 'not') return `NOT (${formatPredicate(predicate.predicate)})`; - if (predicate.kind === 'all' || predicate.kind === 'any') { - return predicate.predicates - .map((item) => `(${formatPredicate(item)})`) - .join(predicate.kind === 'all' ? ' AND ' : ' OR '); - } - const operators = { eq: '==', ne: '!=', gt: '>', gte: '>=', lt: '<', lte: '<=' } as const; - return `${formatOperand(predicate.left)} ${operators[predicate.operator]} ${formatOperand(predicate.right)}`; + if (predicate.kind === 'true') return 'TRUE'; + if (predicate.kind === 'exists') + return `EXISTS ${formatReference(predicate.value)}`; + if (predicate.kind === 'not') + return `NOT (${formatPredicate(predicate.predicate)})`; + if (predicate.kind === 'all' || predicate.kind === 'any') { + return predicate.predicates + .map((item) => `(${formatPredicate(item)})`) + .join(predicate.kind === 'all' ? ' AND ' : ' OR '); + } + const operators = { + eq: '==', + ne: '!=', + gt: '>', + gte: '>=', + lt: '<', + lte: '<=', + } as const; + return `${formatOperand(predicate.left)} ${operators[predicate.operator]} ${formatOperand(predicate.right)}`; } function formatReducer(reducer: Reducer): string[] { - const group = 'groupBy' in reducer && reducer.groupBy - ? ` PER ${formatReference(reducer.groupBy)}` - : ''; - switch (reducer.kind) { - case 'none': - return []; - case 'count': - return [`COUNT ${formatNumber(reducer.threshold)} MATCHES${group}`]; - case 'modulo': - return [`EVERY ${formatNumber(reducer.every)} MATCHES${group}`]; - case 'distinct_count': - return [ - `COUNT ${formatNumber(reducer.threshold)} DISTINCT ${formatReference(reducer.distinctBy)} WITHIN ${formatNumber(reducer.windowSeconds)}s` - ]; - case 'consecutive_count': - return [`COUNT ${formatNumber(reducer.threshold)} CONSECUTIVE MATCHES${group}`]; - case 'sliding_count': - return [ - `COUNT ${formatNumber(reducer.threshold)} MATCHES WITHIN ${formatNumber(reducer.windowSeconds)}s${group}` - ]; - case 'duration': - return [`AFTER CONDITION HOLDS FOR ${formatNumber(reducer.seconds)}s${group}`]; - case 'periodic': - return [`EVERY ${formatExpression(reducer.intervalSeconds)}s WHILE TRUE${group}`]; - case 'derived_scalar': - return [`DERIVE ${formatExpression(reducer.value)}`]; - } + const group = + 'groupBy' in reducer && reducer.groupBy + ? ` PER ${formatReference(reducer.groupBy)}` + : ''; + switch (reducer.kind) { + case 'none': + return []; + case 'count': + return [`COUNT ${formatNumber(reducer.threshold)} MATCHES${group}`]; + case 'modulo': + return [`EVERY ${formatNumber(reducer.every)} MATCHES${group}`]; + case 'distinct_count': + return [ + `COUNT ${formatNumber(reducer.threshold)} DISTINCT ${formatReference(reducer.distinctBy)} WITHIN ${formatNumber(reducer.windowSeconds)}s`, + ]; + case 'consecutive_count': + return [ + `COUNT ${formatNumber(reducer.threshold)} CONSECUTIVE MATCHES${group}`, + ]; + case 'sliding_count': + return [ + `COUNT ${formatNumber(reducer.threshold)} MATCHES WITHIN ${formatNumber(reducer.windowSeconds)}s${group}`, + ]; + case 'duration': + return [ + `AFTER CONDITION HOLDS FOR ${formatNumber(reducer.seconds)}s${group}`, + ]; + case 'periodic': + return [ + `EVERY ${formatExpression(reducer.intervalSeconds)}s WHILE TRUE${group}`, + ]; + case 'derived_scalar': + return [`DERIVE ${formatExpression(reducer.value)}`]; + } } function formatMutation(mutation: ClauseMutation): string { - if (mutation.kind === 'none') return 'NO STATE CHANGE'; - if (mutation.kind === 'transfer') { - const value = mutation.value === undefined || mutation.value === 'all' - ? 'ALL' - : formatExpression(mutation.value); - return `TRANSFER ${value} ${mutation.pool}${formatEntity(mutation.from)} TO${formatEntity(mutation.to).replace(' FOR', '')}`; - } - if (mutation.kind === 'clear') return `CLEAR ${mutation.pool}${formatEntity(mutation.entity)}`; - const verbs = { add: 'ADD', set: 'SET', consume: 'CONSUME', derive: 'DERIVE' } as const; - const preposition = mutation.kind === 'consume' ? 'FROM' : mutation.kind === 'set' || mutation.kind === 'derive' ? 'AS' : 'TO'; - return `${verbs[mutation.kind]} ${formatExpression(mutation.value)} ${preposition} ${mutation.pool}${formatEntity(mutation.entity)}`; + if (mutation.kind === 'none') return 'NO STATE CHANGE'; + if (mutation.kind === 'transfer') { + const value = + mutation.value === undefined || mutation.value === 'all' + ? 'ALL' + : formatExpression(mutation.value); + return `TRANSFER ${value} ${mutation.pool}${formatEntity(mutation.from)} TO${formatEntity(mutation.to).replace(' FOR', '')}`; + } + if (mutation.kind === 'clear') + return `CLEAR ${mutation.pool}${formatEntity(mutation.entity)}`; + const verbs = { + add: 'ADD', + set: 'SET', + consume: 'CONSUME', + derive: 'DERIVE', + } as const; + const preposition = + mutation.kind === 'consume' + ? 'FROM' + : mutation.kind === 'set' || mutation.kind === 'derive' + ? 'AS' + : 'TO'; + return `${verbs[mutation.kind]} ${formatExpression(mutation.value)} ${preposition} ${mutation.pool}${formatEntity(mutation.entity)}`; } function formatOutcome(outcome: MechanicOutcome): string { - const stackBand = outcome.minStacks !== undefined || outcome.maxStacks !== undefined - ? ` AT STACKS ${outcome.minStacks ?? 0}..${outcome.maxStacks ?? 'MAX'}` - : ''; - const perStack = outcome.perStack ? ' PER STACK' : ''; - const attribute = outcome.attributeId ? ` ${outcome.attributeId}` : ''; - const resource = outcome.kind === 'restoration' || outcome.kind === 'resource_cost' - ? ` ${outcomeResource(outcome).toUpperCase()}` - : ''; - const timing = outcome.basis === 'current_health_percent' - ? ` USING ${(outcome.combatTiming ?? 'current') === 'pre_event' ? 'PRE-EVENT' : 'CURRENT'} HEALTH` - : ''; - const delivery = outcome.delivery === 'continuous' - ? `; DELIVER TOTAL CONTINUOUSLY OVER ${formatNumber(outcome.durationSeconds ?? 0)}s` - : ''; - const targeting = outcome.targeting - ? ` TO ${outcome.targeting.population.toUpperCase()} WITHIN ${formatNumber(outcome.targeting.radiusMeters)}m` - : ''; - if (outcome.kind === 'weapon_reload') { - const target = outcome.scope === 'equipped_weapon' ? 'EQUIPPED' : 'INACTIVE'; - return `${outcome.phase.toUpperCase()}: RELOAD ${target} WEAPON AFTER ${formatExpression(outcome.value)}s // ${outcome.label}`; - } - return `${outcome.phase.toUpperCase()}: APPLY ${formatExpression(outcome.value)} ${outcome.basis.toUpperCase()}${resource}${attribute}${perStack}${stackBand}${timing}${targeting}${delivery} // ${outcome.label}`; + const stackBand = + outcome.minStacks !== undefined || outcome.maxStacks !== undefined + ? ` AT STACKS ${outcome.minStacks ?? 0}..${outcome.maxStacks ?? 'MAX'}` + : ''; + const perStack = outcome.perStack ? ' PER STACK' : ''; + const attribute = outcome.attributeId ? ` ${outcome.attributeId}` : ''; + const resource = + outcome.kind === 'restoration' || outcome.kind === 'resource_cost' + ? ` ${outcomeResource(outcome).toUpperCase()}` + : ''; + const timing = + outcome.basis === 'current_health_percent' + ? ` USING ${(outcome.combatTiming ?? 'current') === 'pre_event' ? 'PRE-EVENT' : 'CURRENT'} HEALTH` + : ''; + const delivery = + outcome.delivery === 'continuous' + ? `; DELIVER TOTAL CONTINUOUSLY OVER ${formatNumber(outcome.durationSeconds ?? 0)}s` + : ''; + const targeting = outcome.targeting + ? ` TO ${outcome.targeting.population.toUpperCase()} WITHIN ${formatNumber(outcome.targeting.radiusMeters)}m` + : ''; + if (outcome.kind === 'weapon_reload') { + const target = + outcome.scope === 'equipped_weapon' ? 'EQUIPPED' : 'INACTIVE'; + return `${outcome.phase.toUpperCase()}: RELOAD ${target} WEAPON AFTER ${formatExpression(outcome.value)}s // ${outcome.label}`; + } + return `${outcome.phase.toUpperCase()}: APPLY ${formatExpression(outcome.value)} ${outcome.basis.toUpperCase()}${resource}${attribute}${perStack}${stackBand}${timing}${targeting}${delivery} // ${outcome.label}`; } export function formatMechanicProgram(program: MechanicProgram): string { - const lines = [`MECHANIC ${program.name.toUpperCase()}`]; - for (const pool of program.pools ?? []) { - const bounds = pool.min !== undefined || pool.max !== undefined - ? ` ${formatNumber(pool.min ?? 0)}..${pool.max === undefined ? 'UNBOUNDED' : formatNumber(pool.max)}` - : ''; - const expiry = pool.expiresAfterSeconds ? `; EXPIRES ${formatNumber(pool.expiresAfterSeconds)}s AFTER WRITE` : ''; - lines.push(`STATE ${pool.key} [${pool.scope.toUpperCase()}]${bounds}${expiry}`); - } - for (const clause of program.clauses) { - const recipe = clause.recipeKey ? ` [RECIPE ${clause.recipeKey}]` : ''; - lines.push('', `WHEN ${MECHANIC_EVENT_REGISTRY[clause.when].label.toUpperCase()} // ${clause.label ?? clause.id}${recipe}`); - if (clause.if) lines.push(`IF ${formatPredicate(clause.if)}`); - lines.push(...formatReducer(clause.reducer)); - if (clause.cooldownSeconds) lines.push(`COOLDOWN ${formatNumber(clause.cooldownSeconds)}s`); - if (clause.weight) lines.push(`WEIGHT ${formatExpression(clause.weight)}`); - lines.push(...clause.mutations.map(formatMutation)); - lines.push(...(clause.outcomes ?? []).map(formatOutcome)); - } - return lines.join('\n'); + const lines = [`MECHANIC ${program.name.toUpperCase()}`]; + for (const pool of program.pools ?? []) { + const bounds = + pool.min !== undefined || pool.max !== undefined + ? ` ${formatNumber(pool.min ?? 0)}..${pool.max === undefined ? 'UNBOUNDED' : formatNumber(pool.max)}` + : ''; + const expiry = pool.expiresAfterSeconds + ? `; EXPIRES ${formatNumber(pool.expiresAfterSeconds)}s AFTER WRITE` + : ''; + lines.push( + `STATE ${pool.key} [${pool.scope.toUpperCase()}]${bounds}${expiry}`, + ); + } + for (const clause of program.clauses) { + const recipe = clause.recipeKey ? ` [RECIPE ${clause.recipeKey}]` : ''; + lines.push( + '', + `WHEN ${MECHANIC_EVENT_REGISTRY[clause.when].label.toUpperCase()} // ${clause.label ?? clause.id}${recipe}`, + ); + if (clause.if) lines.push(`IF ${formatPredicate(clause.if)}`); + lines.push(...formatReducer(clause.reducer)); + if (clause.cooldownSeconds) + lines.push(`COOLDOWN ${formatNumber(clause.cooldownSeconds)}s`); + if (clause.weight) lines.push(`WEIGHT ${formatExpression(clause.weight)}`); + lines.push(...clause.mutations.map(formatMutation)); + lines.push(...(clause.outcomes ?? []).map(formatOutcome)); + } + return lines.join('\n'); } diff --git a/src/lib/calculations/skill.test.ts b/src/lib/calculations/skill.test.ts index 394626b..2ce519a 100644 --- a/src/lib/calculations/skill.test.ts +++ b/src/lib/calculations/skill.test.ts @@ -1,268 +1,347 @@ import { describe, expect, it } from 'vitest'; import { - calculateSkillDps, - type SkillAbilityInput, - type SkillCalculationInput + calculateSkillDps, + type SkillAbilityInput, + type SkillCalculationInput, } from './skill.js'; const directAbility = ( - overrides: Partial = {} + overrides: Partial = {}, ): SkillAbilityInput => ({ - id: 'seeker', - baseDamage: 100, - damageType: 'blast', - delivery: { type: 'direct', hits: 2 }, - castSeconds: 0, - cooldownSeconds: 5, - targetCap: 3, - chargePolicy: { type: 'none' }, - activation: { mode: 'on_cooldown' }, - ...overrides + id: 'seeker', + baseDamage: 100, + damageType: 'blast', + delivery: { type: 'direct', hits: 2 }, + castSeconds: 0, + cooldownSeconds: 5, + targetCap: 3, + chargePolicy: { type: 'none' }, + activation: { mode: 'on_cooldown' }, + ...overrides, }); -const base = (overrides: Partial = {}): SkillCalculationInput => ({ - rules: { - revisionId: 'skill-rules-1', - bindings: [ - { attributeId: 'skillDamage', stage: 'skill_damage', operator: 'add' }, - { attributeId: 'criticalChance', stage: 'critical_chance', operator: 'add' }, - { attributeId: 'criticalDamage', stage: 'critical_damage', operator: 'add' }, - { attributeId: 'multishotChance', stage: 'multishot_chance', operator: 'add' }, - { attributeId: 'multishotDamage', stage: 'multishot_damage', operator: 'add' } - ] - }, - abilities: [directAbility()], - attributes: { - skillDamage: 20, - criticalChance: 25, - criticalDamage: 50, - multishotChance: 20, - multishotDamage: 100 - }, - assumptions: { encounterSeconds: 10, targetCount: 5 }, - ...overrides +const base = ( + overrides: Partial = {}, +): SkillCalculationInput => ({ + rules: { + revisionId: 'skill-rules-1', + bindings: [ + { attributeId: 'skillDamage', stage: 'skill_damage', operator: 'add' }, + { + attributeId: 'criticalChance', + stage: 'critical_chance', + operator: 'add', + }, + { + attributeId: 'criticalDamage', + stage: 'critical_damage', + operator: 'add', + }, + { + attributeId: 'multishotChance', + stage: 'multishot_chance', + operator: 'add', + }, + { + attributeId: 'multishotDamage', + stage: 'multishot_damage', + operator: 'add', + }, + ], + }, + abilities: [directAbility()], + attributes: { + skillDamage: 20, + criticalChance: 25, + criticalDamage: 50, + multishotChance: 20, + multishotDamage: 100, + }, + assumptions: { encounterSeconds: 10, targetCount: 5 }, + ...overrides, }); describe('calculateSkillDps', () => { - it('calculates critical and multishot expected damage without a headshot model', () => { - const result = calculateSkillDps(base()); - expect(result.status).toBe('OK'); - if (result.status !== 'OK') return; + it('calculates critical and multishot expected damage without a headshot model', () => { + const result = calculateSkillDps(base()); + expect(result.status).toBe('OK'); + if (result.status !== 'OK') return; - const ability = result.abilities[0]; - expect(result.revisionId).toBe('skill-rules-1'); - expect(ability.activationCount).toBe(2); - expect(ability.damageEventCount).toBe(4); - expect(ability.effectiveTargets).toBe(3); - expect(ability.damage.averageExpectedDamagePerHit).toBe(162); - expect(ability.damage.singleTargetDamagePerActivation).toBe(324); - expect(ability.damage.singleTargetEncounterDamage).toBe(648); - expect(ability.damage.aggregateEncounterDamage).toBe(1944); - expect(ability.damage.singleTargetDps).toBe(64.8); - expect(ability.damage.aggregateDps).toBe(194.4); - }); + const ability = result.abilities[0]; + expect(result.revisionId).toBe('skill-rules-1'); + expect(ability.activationCount).toBe(2); + expect(ability.damageEventCount).toBe(4); + expect(ability.effectiveTargets).toBe(3); + expect(ability.damage.averageExpectedDamagePerHit).toBe(162); + expect(ability.damage.singleTargetDamagePerActivation).toBe(324); + expect(ability.damage.singleTargetEncounterDamage).toBe(648); + expect(ability.damage.aggregateEncounterDamage).toBe(1944); + expect(ability.damage.singleTargetDps).toBe(64.8); + expect(ability.damage.aggregateDps).toBe(194.4); + }); - it('reads current modifiers on every periodic tick instead of snapshotting at cast time', () => { - const result = calculateSkillDps(base({ - abilities: [directAbility({ - id: 'turret', - delivery: { type: 'periodic', ticks: 4, intervalSeconds: 1 }, - cooldownSeconds: 100, - targetCap: 1 - })], - attributes: {}, - rules: { - revisionId: 'dynamic-ticks', - bindings: [{ attributeId: 'skillDamage', stage: 'skill_damage', operator: 'add' }] - }, - modifierWindows: [{ - startSeconds: 2, - endSeconds: 4, - attributes: { skillDamage: 100 }, - abilityIds: ['turret'] - }], - assumptions: { encounterSeconds: 5, targetCount: 1 } - })); - expect(result.status).toBe('OK'); - if (result.status !== 'OK') return; + it('reads current modifiers on every periodic tick instead of snapshotting at cast time', () => { + const result = calculateSkillDps( + base({ + abilities: [ + directAbility({ + id: 'turret', + delivery: { type: 'periodic', ticks: 4, intervalSeconds: 1 }, + cooldownSeconds: 100, + targetCap: 1, + }), + ], + attributes: {}, + rules: { + revisionId: 'dynamic-ticks', + bindings: [ + { + attributeId: 'skillDamage', + stage: 'skill_damage', + operator: 'add', + }, + ], + }, + modifierWindows: [ + { + startSeconds: 2, + endSeconds: 4, + attributes: { skillDamage: 100 }, + abilityIds: ['turret'], + }, + ], + assumptions: { encounterSeconds: 5, targetCount: 1 }, + }), + ); + expect(result.status).toBe('OK'); + if (result.status !== 'OK') return; - expect(result.abilities[0].damage.singleTargetEncounterDamage).toBe(600); - expect(result.abilities[0].damage.averageExpectedDamagePerHit).toBe(150); - }); + expect(result.abilities[0].damage.singleTargetEncounterDamage).toBe(600); + expect(result.abilities[0].damage.averageExpectedDamagePerHit).toBe(150); + }); - it('supports multiple delivered hits on each periodic tick', () => { - const result = calculateSkillDps(base({ - abilities: [directAbility({ - id: 'multi-hit-dot', - delivery: { type: 'periodic', ticks: 2, hitsPerTick: 3, intervalSeconds: 1 }, - cooldownSeconds: 100, - targetCap: 1 - })], - attributes: {}, - rules: { revisionId: 'periodic-hits', bindings: [] }, - assumptions: { encounterSeconds: 5, targetCount: 1 } - })); - expect(result.status).toBe('OK'); - if (result.status !== 'OK') return; + it('supports multiple delivered hits on each periodic tick', () => { + const result = calculateSkillDps( + base({ + abilities: [ + directAbility({ + id: 'multi-hit-dot', + delivery: { + type: 'periodic', + ticks: 2, + hitsPerTick: 3, + intervalSeconds: 1, + }, + cooldownSeconds: 100, + targetCap: 1, + }), + ], + attributes: {}, + rules: { revisionId: 'periodic-hits', bindings: [] }, + assumptions: { encounterSeconds: 5, targetCount: 1 }, + }), + ); + expect(result.status).toBe('OK'); + if (result.status !== 'OK') return; - expect(result.abilities[0].damageEventCount).toBe(6); - expect(result.abilities[0].damage.singleTargetEncounterDamage).toBe(600); - }); + expect(result.abilities[0].damageEventCount).toBe(6); + expect(result.abilities[0].damage.singleTargetEncounterDamage).toBe(600); + }); - it('applies published cooldown recovery and duration stages to cadence', () => { - const result = calculateSkillDps(base({ - abilities: [directAbility({ - id: 'cadence', - delivery: { - type: 'periodic', - ticks: 2, - hitsPerTick: 1, - intervalSeconds: 1, - firstTickDelaySeconds: 1 - }, - cooldownSeconds: 10, - targetCap: 1 - })], - attributes: { recovery: 100, duration: 100 }, - rules: { - revisionId: 'skill-cadence', - bindings: [ - { attributeId: 'recovery', stage: 'cooldown_recovery', operator: 'add' }, - { attributeId: 'duration', stage: 'duration', operator: 'add' } - ] - }, - assumptions: { encounterSeconds: 11, targetCount: 1 } - })); - expect(result.status).toBe('OK'); - if (result.status !== 'OK') return; + it('applies published cooldown recovery and duration stages to cadence', () => { + const result = calculateSkillDps( + base({ + abilities: [ + directAbility({ + id: 'cadence', + delivery: { + type: 'periodic', + ticks: 2, + hitsPerTick: 1, + intervalSeconds: 1, + firstTickDelaySeconds: 1, + }, + cooldownSeconds: 10, + targetCap: 1, + }), + ], + attributes: { recovery: 100, duration: 100 }, + rules: { + revisionId: 'skill-cadence', + bindings: [ + { + attributeId: 'recovery', + stage: 'cooldown_recovery', + operator: 'add', + }, + { attributeId: 'duration', stage: 'duration', operator: 'add' }, + ], + }, + assumptions: { encounterSeconds: 11, targetCount: 1 }, + }), + ); + expect(result.status).toBe('OK'); + if (result.status !== 'OK') return; - expect(result.abilities[0].activationCount).toBe(3); - expect(result.abilities[0].damageEventCount).toBe(8); - expect(result.abilities[0].damage.singleTargetEncounterDamage).toBe(800); - }); + expect(result.abilities[0].activationCount).toBe(3); + expect(result.abilities[0].damageEventCount).toBe(8); + expect(result.abilities[0].damage.singleTargetEncounterDamage).toBe(800); + }); - it('supports custom uses per minute and clips damage events at encounter end', () => { - const result = calculateSkillDps(base({ - abilities: [directAbility({ - delivery: { type: 'periodic', ticks: 3, intervalSeconds: 2 }, - cooldownSeconds: 0, - targetCap: 1, - activation: { mode: 'custom_per_minute', usesPerMinute: 4 } - })], - attributes: {}, - rules: { revisionId: 'custom-rate', bindings: [] }, - assumptions: { encounterSeconds: 31, targetCount: 1 } - })); - expect(result.status).toBe('OK'); - if (result.status !== 'OK') return; + it('supports custom uses per minute and clips damage events at encounter end', () => { + const result = calculateSkillDps( + base({ + abilities: [ + directAbility({ + delivery: { type: 'periodic', ticks: 3, intervalSeconds: 2 }, + cooldownSeconds: 0, + targetCap: 1, + activation: { mode: 'custom_per_minute', usesPerMinute: 4 }, + }), + ], + attributes: {}, + rules: { revisionId: 'custom-rate', bindings: [] }, + assumptions: { encounterSeconds: 31, targetCount: 1 }, + }), + ); + expect(result.status).toBe('OK'); + if (result.status !== 'OK') return; - expect(result.abilities[0].activationCount).toBe(3); - expect(result.abilities[0].damageEventCount).toBe(7); - expect(result.abilities[0].damage.singleTargetEncounterDamage).toBe(700); - }); + expect(result.abilities[0].activationCount).toBe(3); + expect(result.abilities[0].damageEventCount).toBe(7); + expect(result.abilities[0].damage.singleTargetEncounterDamage).toBe(700); + }); - it('models independently recharging charges and full-set recharge differently', () => { - const shared = { - delivery: { type: 'direct', hits: 1 } as const, - castSeconds: 2, - cooldownSeconds: 10, - targetCap: 1, - activation: { mode: 'on_cooldown' } as const - }; - const result = calculateSkillDps(base({ - abilities: [ - directAbility({ - ...shared, - id: 'independent', - chargePolicy: { type: 'independent', charges: 3 } - }), - directAbility({ - ...shared, - id: 'full-set', - chargePolicy: { type: 'full_set', charges: 3 } - }) - ], - attributes: {}, - rules: { revisionId: 'charges', bindings: [] }, - assumptions: { encounterSeconds: 13, targetCount: 1 } - })); - expect(result.status).toBe('OK'); - if (result.status !== 'OK') return; + it('models independently recharging charges and full-set recharge differently', () => { + const shared = { + delivery: { type: 'direct', hits: 1 } as const, + castSeconds: 2, + cooldownSeconds: 10, + targetCap: 1, + activation: { mode: 'on_cooldown' } as const, + }; + const result = calculateSkillDps( + base({ + abilities: [ + directAbility({ + ...shared, + id: 'independent', + chargePolicy: { type: 'independent', charges: 3 }, + }), + directAbility({ + ...shared, + id: 'full-set', + chargePolicy: { type: 'full_set', charges: 3 }, + }), + ], + attributes: {}, + rules: { revisionId: 'charges', bindings: [] }, + assumptions: { encounterSeconds: 13, targetCount: 1 }, + }), + ); + expect(result.status).toBe('OK'); + if (result.status !== 'OK') return; - expect(result.abilities.find((item) => item.abilityId === 'independent')?.activationCount).toBe(5); - expect(result.abilities.find((item) => item.abilityId === 'full-set')?.activationCount).toBe(3); - }); + expect( + result.abilities.find((item) => item.abilityId === 'independent') + ?.activationCount, + ).toBe(5); + expect( + result.abilities.find((item) => item.abilityId === 'full-set') + ?.activationCount, + ).toBe(3); + }); - it('combines every enabled ability and excludes disabled abilities', () => { - const result = calculateSkillDps(base({ - abilities: [ - directAbility({ id: 'one', delivery: { type: 'direct', hits: 1 } }), - directAbility({ id: 'two', delivery: { type: 'direct', hits: 1 }, baseDamage: 200 }), - directAbility({ - id: 'off', - baseDamage: undefined, - activation: { mode: 'off' } - }) - ], - attributes: {}, - rules: { revisionId: 'combined', bindings: [] }, - assumptions: { encounterSeconds: 10, targetCount: 1 } - })); - expect(result.status).toBe('OK'); - if (result.status !== 'OK') return; + it('combines every enabled ability and excludes disabled abilities', () => { + const result = calculateSkillDps( + base({ + abilities: [ + directAbility({ id: 'one', delivery: { type: 'direct', hits: 1 } }), + directAbility({ + id: 'two', + delivery: { type: 'direct', hits: 1 }, + baseDamage: 200, + }), + directAbility({ + id: 'off', + baseDamage: undefined, + activation: { mode: 'off' }, + }), + ], + attributes: {}, + rules: { revisionId: 'combined', bindings: [] }, + assumptions: { encounterSeconds: 10, targetCount: 1 }, + }), + ); + expect(result.status).toBe('OK'); + if (result.status !== 'OK') return; - expect(result.abilities.find((item) => item.abilityId === 'off')).toMatchObject({ - enabled: false, - activationCount: 0 - }); - expect(result.combined.singleTargetEncounterDamage).toBe(600); - expect(result.combined.singleTargetDps).toBe(60); - }); + expect( + result.abilities.find((item) => item.abilityId === 'off'), + ).toMatchObject({ + enabled: false, + activationCount: 0, + }); + expect(result.combined.singleTargetEncounterDamage).toBe(600); + expect(result.combined.singleTargetDps).toBe(60); + }); - it('uses each ability target assumption before applying its target cap', () => { - const result = calculateSkillDps(base({ - abilities: [ - directAbility({ id: 'single', targetCount: 1 }), - directAbility({ id: 'crowd', targetCount: 2 }) - ], - attributes: {}, - rules: { revisionId: 'per-ability-targets', bindings: [] }, - assumptions: { encounterSeconds: 10, targetCount: 5 } - })); - expect(result.status).toBe('OK'); - if (result.status !== 'OK') return; + it('uses each ability target assumption before applying its target cap', () => { + const result = calculateSkillDps( + base({ + abilities: [ + directAbility({ id: 'single', targetCount: 1 }), + directAbility({ id: 'crowd', targetCount: 2 }), + ], + attributes: {}, + rules: { revisionId: 'per-ability-targets', bindings: [] }, + assumptions: { encounterSeconds: 10, targetCount: 5 }, + }), + ); + expect(result.status).toBe('OK'); + if (result.status !== 'OK') return; - expect(result.abilities.find((item) => item.abilityId === 'single')?.effectiveTargets).toBe(1); - expect(result.abilities.find((item) => item.abilityId === 'crowd')?.effectiveTargets).toBe(2); - }); + expect( + result.abilities.find((item) => item.abilityId === 'single') + ?.effectiveTargets, + ).toBe(1); + expect( + result.abilities.find((item) => item.abilityId === 'crowd') + ?.effectiveTargets, + ).toBe(2); + }); - it('returns explicit missing requirements instead of partial damage', () => { - const result = calculateSkillDps({ - abilities: [directAbility({ - baseDamage: undefined, - targetCap: undefined, - chargePolicy: undefined, - activation: { mode: 'custom_per_minute', usesPerMinute: 0 } - })], - attributes: { unknownSkillAttribute: 10 }, - assumptions: {} - }); - expect(result).toMatchObject({ - status: 'INSUFFICIENT_DATA', - revisionId: null, - abilities: [], - combined: null - }); - expect(result.missing).toEqual(expect.arrayContaining([ - 'rules.revisionId', - 'rules.bindings', - 'assumptions.encounterSeconds', - 'assumptions.targetCount', - 'ability:seeker:baseDamage', - 'ability:seeker:targetCap', - 'ability:seeker:chargePolicy', - 'ability:seeker:activation.usesPerMinute', - 'binding:unknownSkillAttribute' - ])); - }); + it('returns explicit missing requirements instead of partial damage', () => { + const result = calculateSkillDps({ + abilities: [ + directAbility({ + baseDamage: undefined, + targetCap: undefined, + chargePolicy: undefined, + activation: { mode: 'custom_per_minute', usesPerMinute: 0 }, + }), + ], + attributes: { unknownSkillAttribute: 10 }, + assumptions: {}, + }); + expect(result).toMatchObject({ + status: 'INSUFFICIENT_DATA', + revisionId: null, + abilities: [], + combined: null, + }); + expect(result.missing).toEqual( + expect.arrayContaining([ + 'rules.revisionId', + 'rules.bindings', + 'assumptions.encounterSeconds', + 'assumptions.targetCount', + 'ability:seeker:baseDamage', + 'ability:seeker:targetCap', + 'ability:seeker:chargePolicy', + 'ability:seeker:activation.usesPerMinute', + 'binding:unknownSkillAttribute', + ]), + ); + }); }); diff --git a/src/lib/calculations/skill.ts b/src/lib/calculations/skill.ts index f1594a3..4839bbb 100644 --- a/src/lib/calculations/skill.ts +++ b/src/lib/calculations/skill.ts @@ -1,553 +1,658 @@ export type SkillStatValues = Record; export type SkillCalculationStage = - | 'flat_damage' - | 'skill_damage' - | 'final_damage' - | 'critical_chance' - | 'critical_damage' - | 'multishot_chance' - | 'multishot_damage' - | 'cooldown_recovery' - | 'duration'; + | 'flat_damage' + | 'skill_damage' + | 'final_damage' + | 'critical_chance' + | 'critical_damage' + | 'multishot_chance' + | 'multishot_damage' + | 'cooldown_recovery' + | 'duration'; export type SkillAttributeBinding = { - attributeId: string; - stage: SkillCalculationStage; - operator: 'add' | 'multiply'; - weight?: number; + attributeId: string; + stage: SkillCalculationStage; + operator: 'add' | 'multiply'; + weight?: number; }; export type SkillCalculationRules = { - revisionId: string; - bindings: SkillAttributeBinding[]; - stageOrder?: SkillCalculationStage[]; + revisionId: string; + bindings: SkillAttributeBinding[]; + stageOrder?: SkillCalculationStage[]; }; export type SkillDelivery = - | { type: 'direct'; hits: number } - | { - type: 'periodic'; - ticks: number; - durationSeconds?: number; - hitsPerTick?: number; - intervalSeconds: number; - firstTickDelaySeconds?: number; - }; + | { type: 'direct'; hits: number } + | { + type: 'periodic'; + ticks: number; + durationSeconds?: number; + hitsPerTick?: number; + intervalSeconds: number; + firstTickDelaySeconds?: number; + }; export type SkillChargePolicy = - | { type: 'none' } - | { type: 'independent'; charges: number } - | { type: 'full_set'; charges: number }; + | { type: 'none' } + | { type: 'independent'; charges: number } + | { type: 'full_set'; charges: number }; export type SkillActivation = - | { mode: 'off' } - | { mode: 'on_cooldown' } - | { mode: 'custom_per_minute'; usesPerMinute: number }; + | { mode: 'off' } + | { mode: 'on_cooldown' } + | { mode: 'custom_per_minute'; usesPerMinute: number }; export type SkillAbilityInput = { - id: string; - baseDamage?: number; - damageType?: string; - delivery: SkillDelivery; - castSeconds?: number; - cooldownSeconds?: number; - targetCap?: number; - targetCount?: number; - chargePolicy?: SkillChargePolicy; - activation: SkillActivation; + id: string; + baseDamage?: number; + damageType?: string; + delivery: SkillDelivery; + castSeconds?: number; + cooldownSeconds?: number; + targetCap?: number; + targetCount?: number; + chargePolicy?: SkillChargePolicy; + activation: SkillActivation; }; export type SkillModifierWindow = { - startSeconds: number; - endSeconds: number; - attributes: SkillStatValues; - abilityIds?: string[]; + startSeconds: number; + endSeconds: number; + attributes: SkillStatValues; + abilityIds?: string[]; }; export type SkillCalculationInput = { - rules?: SkillCalculationRules; - abilities: SkillAbilityInput[]; - attributes?: SkillStatValues; - attributeRules?: Record; - modifierWindows?: SkillModifierWindow[]; - assumptions: { - encounterSeconds?: number; - targetCount?: number; - }; + rules?: SkillCalculationRules; + abilities: SkillAbilityInput[]; + attributes?: SkillStatValues; + attributeRules?: Record< + string, + { + combination: 'additive' | 'multiplicative_percent'; + cap?: number | null; + } + >; + modifierWindows?: SkillModifierWindow[]; + assumptions: { + encounterSeconds?: number; + targetCount?: number; + }; }; export type SkillDamageSummary = { - unmodifiedBaseDamage: number; - averageExpectedDamagePerHit: number; - singleTargetDamagePerActivation: number; - aggregateDamagePerActivation: number; - singleTargetEncounterDamage: number; - aggregateEncounterDamage: number; - singleTargetDps: number; - aggregateDps: number; + unmodifiedBaseDamage: number; + averageExpectedDamagePerHit: number; + singleTargetDamagePerActivation: number; + aggregateDamagePerActivation: number; + singleTargetEncounterDamage: number; + aggregateEncounterDamage: number; + singleTargetDps: number; + aggregateDps: number; }; export type SkillAbilityCalculation = { - abilityId: string; - damageType: string; - enabled: boolean; - activationCount: number; - damageEventCount: number; - effectiveTargets: number; - damage: SkillDamageSummary; + abilityId: string; + damageType: string; + enabled: boolean; + activationCount: number; + damageEventCount: number; + effectiveTargets: number; + damage: SkillDamageSummary; }; export type SkillCombinedCalculation = { - singleTargetEncounterDamage: number; - aggregateEncounterDamage: number; - singleTargetDps: number; - aggregateDps: number; + singleTargetEncounterDamage: number; + aggregateEncounterDamage: number; + singleTargetDps: number; + aggregateDps: number; }; export type SkillCalculation = - | { - status: 'INSUFFICIENT_DATA'; - revisionId: string | null; - missing: string[]; - abilities: []; - combined: null; - } - | { - status: 'OK'; - revisionId: string; - missing: []; - abilities: SkillAbilityCalculation[]; - combined: SkillCombinedCalculation; - }; + | { + status: 'INSUFFICIENT_DATA'; + revisionId: string | null; + missing: string[]; + abilities: []; + combined: null; + } + | { + status: 'OK'; + revisionId: string; + missing: []; + abilities: SkillAbilityCalculation[]; + combined: SkillCombinedCalculation; + }; type DamageEvent = { - activationIndex: number; - time: number; + activationIndex: number; + time: number; }; const EPSILON = 0.000_001; const MAX_EVENTS = 100_000; const isFiniteNumber = (value: unknown) => Number.isFinite(Number(value)); -const isNonNegative = (value: unknown) => isFiniteNumber(value) && Number(value) >= 0; -const isPositive = (value: unknown) => isFiniteNumber(value) && Number(value) > 0; -const isPositiveInteger = (value: unknown) => Number.isInteger(Number(value)) && Number(value) > 0; +const isNonNegative = (value: unknown) => + isFiniteNumber(value) && Number(value) >= 0; +const isPositive = (value: unknown) => + isFiniteNumber(value) && Number(value) > 0; +const isPositiveInteger = (value: unknown) => + Number.isInteger(Number(value)) && Number(value) > 0; const clamp = (value: number, minimum: number, maximum: number) => - Math.min(maximum, Math.max(minimum, value)); + Math.min(maximum, Math.max(minimum, value)); function requiredData(input: SkillCalculationInput) { - const missing: string[] = []; - const enabled = input.abilities.filter((ability) => ability.activation.mode !== 'off'); + const missing: string[] = []; + const enabled = input.abilities.filter( + (ability) => ability.activation.mode !== 'off', + ); - if (enabled.length) { - if (!input.rules?.revisionId?.trim()) missing.push('rules.revisionId'); - if (!Array.isArray(input.rules?.bindings)) missing.push('rules.bindings'); - if (!isPositive(input.assumptions.encounterSeconds)) missing.push('assumptions.encounterSeconds'); - if (!isPositiveInteger(input.assumptions.targetCount)) missing.push('assumptions.targetCount'); - } + if (enabled.length) { + if (!input.rules?.revisionId?.trim()) missing.push('rules.revisionId'); + if (!Array.isArray(input.rules?.bindings)) missing.push('rules.bindings'); + if (!isPositive(input.assumptions.encounterSeconds)) + missing.push('assumptions.encounterSeconds'); + if (!isPositiveInteger(input.assumptions.targetCount)) + missing.push('assumptions.targetCount'); + } - const seenIds = new Set(); - for (const ability of enabled) { - const prefix = `ability:${ability.id || 'unknown'}`; - if (!ability.id.trim()) missing.push(`${prefix}:id`); - if (seenIds.has(ability.id)) missing.push(`${prefix}:duplicate`); - seenIds.add(ability.id); - if (!isPositive(ability.baseDamage)) missing.push(`${prefix}:baseDamage`); - if (!ability.damageType?.trim()) missing.push(`${prefix}:damageType`); - if (!isNonNegative(ability.castSeconds)) missing.push(`${prefix}:castSeconds`); - if (!isNonNegative(ability.cooldownSeconds)) missing.push(`${prefix}:cooldownSeconds`); - if (!isPositiveInteger(ability.targetCap)) missing.push(`${prefix}:targetCap`); - if (ability.targetCount !== undefined && !isPositiveInteger(ability.targetCount)) { - missing.push(`${prefix}:targetCount`); - } - if (!ability.chargePolicy) missing.push(`${prefix}:chargePolicy`); - if (ability.chargePolicy?.type !== 'none' && !isPositiveInteger(ability.chargePolicy?.charges)) { - missing.push(`${prefix}:chargePolicy.charges`); - } - if (ability.delivery.type === 'direct') { - if (!isPositiveInteger(ability.delivery.hits)) missing.push(`${prefix}:delivery.hits`); - } else { - if (!isPositiveInteger(ability.delivery.ticks)) missing.push(`${prefix}:delivery.ticks`); - if ( - ability.delivery.durationSeconds !== undefined - && !isPositive(ability.delivery.durationSeconds) - ) { - missing.push(`${prefix}:delivery.durationSeconds`); - } - if (!isPositiveInteger(ability.delivery.hitsPerTick ?? 1)) { - missing.push(`${prefix}:delivery.hitsPerTick`); - } - if (!isNonNegative(ability.delivery.intervalSeconds)) { - missing.push(`${prefix}:delivery.intervalSeconds`); - } - if (!isNonNegative(ability.delivery.firstTickDelaySeconds ?? 0)) { - missing.push(`${prefix}:delivery.firstTickDelaySeconds`); - } - } - if ( - ability.activation.mode === 'custom_per_minute' - && !isPositive(ability.activation.usesPerMinute) - ) { - missing.push(`${prefix}:activation.usesPerMinute`); - } - if ( - ability.activation.mode === 'on_cooldown' - && !isPositive(ability.cooldownSeconds) - && !isPositive(ability.castSeconds) - ) { - missing.push(`${prefix}:activation.cadence`); - } - } + const seenIds = new Set(); + for (const ability of enabled) { + const prefix = `ability:${ability.id || 'unknown'}`; + if (!ability.id.trim()) missing.push(`${prefix}:id`); + if (seenIds.has(ability.id)) missing.push(`${prefix}:duplicate`); + seenIds.add(ability.id); + if (!isPositive(ability.baseDamage)) missing.push(`${prefix}:baseDamage`); + if (!ability.damageType?.trim()) missing.push(`${prefix}:damageType`); + if (!isNonNegative(ability.castSeconds)) + missing.push(`${prefix}:castSeconds`); + if (!isNonNegative(ability.cooldownSeconds)) + missing.push(`${prefix}:cooldownSeconds`); + if (!isPositiveInteger(ability.targetCap)) + missing.push(`${prefix}:targetCap`); + if ( + ability.targetCount !== undefined && + !isPositiveInteger(ability.targetCount) + ) { + missing.push(`${prefix}:targetCount`); + } + if (!ability.chargePolicy) missing.push(`${prefix}:chargePolicy`); + if ( + ability.chargePolicy?.type !== 'none' && + !isPositiveInteger(ability.chargePolicy?.charges) + ) { + missing.push(`${prefix}:chargePolicy.charges`); + } + if (ability.delivery.type === 'direct') { + if (!isPositiveInteger(ability.delivery.hits)) + missing.push(`${prefix}:delivery.hits`); + } else { + if (!isPositiveInteger(ability.delivery.ticks)) + missing.push(`${prefix}:delivery.ticks`); + if ( + ability.delivery.durationSeconds !== undefined && + !isPositive(ability.delivery.durationSeconds) + ) { + missing.push(`${prefix}:delivery.durationSeconds`); + } + if (!isPositiveInteger(ability.delivery.hitsPerTick ?? 1)) { + missing.push(`${prefix}:delivery.hitsPerTick`); + } + if (!isNonNegative(ability.delivery.intervalSeconds)) { + missing.push(`${prefix}:delivery.intervalSeconds`); + } + if (!isNonNegative(ability.delivery.firstTickDelaySeconds ?? 0)) { + missing.push(`${prefix}:delivery.firstTickDelaySeconds`); + } + } + if ( + ability.activation.mode === 'custom_per_minute' && + !isPositive(ability.activation.usesPerMinute) + ) { + missing.push(`${prefix}:activation.usesPerMinute`); + } + if ( + ability.activation.mode === 'on_cooldown' && + !isPositive(ability.cooldownSeconds) && + !isPositive(ability.castSeconds) + ) { + missing.push(`${prefix}:activation.cadence`); + } + } - for (const [index, window] of (input.modifierWindows ?? []).entries()) { - if (!isNonNegative(window.startSeconds)) { - missing.push(`modifierWindow:${index}:startSeconds`); - } - if (!isPositive(window.endSeconds) || window.endSeconds <= window.startSeconds) { - missing.push(`modifierWindow:${index}:endSeconds`); - } - for (const [attributeId, value] of Object.entries(window.attributes)) { - if (!isFiniteNumber(value)) missing.push(`modifierWindow:${index}:attribute:${attributeId}`); - } - } + for (const [index, window] of (input.modifierWindows ?? []).entries()) { + if (!isNonNegative(window.startSeconds)) { + missing.push(`modifierWindow:${index}:startSeconds`); + } + if ( + !isPositive(window.endSeconds) || + window.endSeconds <= window.startSeconds + ) { + missing.push(`modifierWindow:${index}:endSeconds`); + } + for (const [attributeId, value] of Object.entries(window.attributes)) { + if (!isFiniteNumber(value)) + missing.push(`modifierWindow:${index}:attribute:${attributeId}`); + } + } - for (const [attributeId, value] of Object.entries(input.attributes ?? {})) { - if (!isFiniteNumber(value)) missing.push(`attribute:${attributeId}`); - } + for (const [attributeId, value] of Object.entries(input.attributes ?? {})) { + if (!isFiniteNumber(value)) missing.push(`attribute:${attributeId}`); + } - const boundIds = new Set(input.rules?.bindings.map((binding) => binding.attributeId) ?? []); - const suppliedIds = new Set([ - ...Object.keys(input.attributes ?? {}), - ...(input.modifierWindows ?? []).flatMap((window) => Object.keys(window.attributes)) - ]); - for (const attributeId of suppliedIds) { - if (!boundIds.has(attributeId)) missing.push(`binding:${attributeId}`); - } + const boundIds = new Set( + input.rules?.bindings.map((binding) => binding.attributeId) ?? [], + ); + const suppliedIds = new Set([ + ...Object.keys(input.attributes ?? {}), + ...(input.modifierWindows ?? []).flatMap((window) => + Object.keys(window.attributes), + ), + ]); + for (const attributeId of suppliedIds) { + if (!boundIds.has(attributeId)) missing.push(`binding:${attributeId}`); + } - return [...new Set(missing)]; + return [...new Set(missing)]; } function activationInterval(ability: SkillAbilityInput) { - return ability.activation.mode === 'custom_per_minute' - ? 60 / ability.activation.usesPerMinute - : 0; + return ability.activation.mode === 'custom_per_minute' + ? 60 / ability.activation.usesPerMinute + : 0; } function stageFactorAt( - input: SkillCalculationInput, - rules: SkillCalculationRules, - abilityId: string, - time: number, - stage: 'cooldown_recovery' | 'duration' + input: SkillCalculationInput, + rules: SkillCalculationRules, + abilityId: string, + time: number, + stage: 'cooldown_recovery' | 'duration', ) { - const values = stageValues(attributesAt(input, abilityId, time), rules.bindings, stage); - return Math.max(EPSILON, (1 + values.additive / 100) * values.multiplicative); + const values = stageValues( + attributesAt(input, abilityId, time), + rules.bindings, + stage, + ); + return Math.max(EPSILON, (1 + values.additive / 100) * values.multiplicative); } export function skillActivationTimes( - ability: SkillAbilityInput, - encounterSeconds: number, - input: SkillCalculationInput, - rules: SkillCalculationRules + ability: SkillAbilityInput, + encounterSeconds: number, + input: SkillCalculationInput, + rules: SkillCalculationRules, ) { - if (ability.activation.mode === 'off') return []; + if (ability.activation.mode === 'off') return []; - const castSeconds = Number(ability.castSeconds); - const cooldownAt = (time: number) => Number(ability.cooldownSeconds) - / stageFactorAt(input, rules, ability.id, time, 'cooldown_recovery'); - const requestedInterval = activationInterval(ability); - const policy = ability.chargePolicy!; - const times: number[] = []; - let lastActivation = -Infinity; - let nextRequested = 0; + const castSeconds = Number(ability.castSeconds); + const cooldownAt = (time: number) => + Number(ability.cooldownSeconds) / + stageFactorAt(input, rules, ability.id, time, 'cooldown_recovery'); + const requestedInterval = activationInterval(ability); + const policy = ability.chargePolicy!; + const times: number[] = []; + let lastActivation = -Infinity; + let nextRequested = 0; - if (policy.type === 'none') { - for ( - let time = 0; - time < encounterSeconds - EPSILON && times.length < MAX_EVENTS; - time += Math.max(castSeconds, cooldownAt(time), requestedInterval) - ) { - times.push(time); - } - return times; - } + if (policy.type === 'none') { + for ( + let time = 0; + time < encounterSeconds - EPSILON && times.length < MAX_EVENTS; + time += Math.max(castSeconds, cooldownAt(time), requestedInterval) + ) { + times.push(time); + } + return times; + } - if (policy.type === 'independent') { - const readyAt = Array.from({ length: policy.charges }, () => 0); - while (times.length < MAX_EVENTS) { - const chargeIndex = readyAt.reduce( - (earliest, ready, index) => ready < readyAt[earliest] ? index : earliest, - 0 - ); - const time = Math.max( - lastActivation + castSeconds, - readyAt[chargeIndex], - ability.activation.mode === 'custom_per_minute' ? nextRequested : 0 - ); - if (time >= encounterSeconds - EPSILON) break; - times.push(time); - lastActivation = time; - readyAt[chargeIndex] = time + cooldownAt(time); - if (ability.activation.mode === 'custom_per_minute') nextRequested = time + requestedInterval; - } - return times; - } + if (policy.type === 'independent') { + const readyAt = Array.from({ length: policy.charges }, () => 0); + while (times.length < MAX_EVENTS) { + const chargeIndex = readyAt.reduce( + (earliest, ready, index) => + ready < readyAt[earliest] ? index : earliest, + 0, + ); + const time = Math.max( + lastActivation + castSeconds, + readyAt[chargeIndex], + ability.activation.mode === 'custom_per_minute' ? nextRequested : 0, + ); + if (time >= encounterSeconds - EPSILON) break; + times.push(time); + lastActivation = time; + readyAt[chargeIndex] = time + cooldownAt(time); + if (ability.activation.mode === 'custom_per_minute') + nextRequested = time + requestedInterval; + } + return times; + } - let charges = policy.charges; - let fullRechargeAt = Infinity; - while (times.length < MAX_EVENTS) { - let time = Math.max( - lastActivation + castSeconds, - ability.activation.mode === 'custom_per_minute' ? nextRequested : 0 - ); - if (charges === 0) { - time = Math.max(time, fullRechargeAt); - charges = policy.charges; - fullRechargeAt = Infinity; - } - if (time >= encounterSeconds - EPSILON) break; - times.push(time); - lastActivation = time; - charges -= 1; - if (charges === 0) fullRechargeAt = time + cooldownAt(time); - if (ability.activation.mode === 'custom_per_minute') nextRequested = time + requestedInterval; - } - return times; + let charges = policy.charges; + let fullRechargeAt = Infinity; + while (times.length < MAX_EVENTS) { + let time = Math.max( + lastActivation + castSeconds, + ability.activation.mode === 'custom_per_minute' ? nextRequested : 0, + ); + if (charges === 0) { + time = Math.max(time, fullRechargeAt); + charges = policy.charges; + fullRechargeAt = Infinity; + } + if (time >= encounterSeconds - EPSILON) break; + times.push(time); + lastActivation = time; + charges -= 1; + if (charges === 0) fullRechargeAt = time + cooldownAt(time); + if (ability.activation.mode === 'custom_per_minute') + nextRequested = time + requestedInterval; + } + return times; } function damageEvents( - ability: SkillAbilityInput, - activations: number[], - encounterSeconds: number, - input: SkillCalculationInput, - rules: SkillCalculationRules + ability: SkillAbilityInput, + activations: number[], + encounterSeconds: number, + input: SkillCalculationInput, + rules: SkillCalculationRules, ) { - const events: DamageEvent[] = []; - for (const [activationIndex, activationTime] of activations.entries()) { - const deliveryTime = activationTime + Number(ability.castSeconds); - if (ability.delivery.type === 'direct') { - for (let hit = 0; hit < ability.delivery.hits; hit += 1) { - if (deliveryTime < encounterSeconds - EPSILON) { - events.push({ activationIndex, time: deliveryTime }); - } - } - continue; - } - const firstTick = deliveryTime + Number(ability.delivery.firstTickDelaySeconds ?? 0); - const durationFactor = stageFactorAt(input, rules, ability.id, activationTime, 'duration'); - const baseDuration = Number( - ability.delivery.durationSeconds - ?? ability.delivery.ticks * ability.delivery.intervalSeconds - ); - const ticks = Math.max( - 1, - Math.floor(baseDuration * durationFactor / ability.delivery.intervalSeconds) - ); - for (let tick = 0; tick < ticks; tick += 1) { - const time = firstTick + tick * ability.delivery.intervalSeconds; - if (time < encounterSeconds - EPSILON) { - for (let hit = 0; hit < Number(ability.delivery.hitsPerTick ?? 1); hit += 1) { - events.push({ activationIndex, time }); - } - } - } - } - return events; + const events: DamageEvent[] = []; + for (const [activationIndex, activationTime] of activations.entries()) { + const deliveryTime = activationTime + Number(ability.castSeconds); + if (ability.delivery.type === 'direct') { + for (let hit = 0; hit < ability.delivery.hits; hit += 1) { + if (deliveryTime < encounterSeconds - EPSILON) { + events.push({ activationIndex, time: deliveryTime }); + } + } + continue; + } + const firstTick = + deliveryTime + Number(ability.delivery.firstTickDelaySeconds ?? 0); + const durationFactor = stageFactorAt( + input, + rules, + ability.id, + activationTime, + 'duration', + ); + const baseDuration = Number( + ability.delivery.durationSeconds ?? + ability.delivery.ticks * ability.delivery.intervalSeconds, + ); + const ticks = Math.max( + 1, + Math.floor( + (baseDuration * durationFactor) / ability.delivery.intervalSeconds, + ), + ); + for (let tick = 0; tick < ticks; tick += 1) { + const time = firstTick + tick * ability.delivery.intervalSeconds; + if (time < encounterSeconds - EPSILON) { + for ( + let hit = 0; + hit < Number(ability.delivery.hitsPerTick ?? 1); + hit += 1 + ) { + events.push({ activationIndex, time }); + } + } + } + } + return events; } function attributesAt( - input: SkillCalculationInput, - abilityId: string, - time: number + input: SkillCalculationInput, + abilityId: string, + time: number, ) { - const attributes = { ...(input.attributes ?? {}) }; - for (const window of input.modifierWindows ?? []) { - const appliesToAbility = !window.abilityIds?.length || window.abilityIds.includes(abilityId); - if (!appliesToAbility || time < window.startSeconds || time >= window.endSeconds) continue; - for (const [attributeId, value] of Object.entries(window.attributes)) { - const current = attributes[attributeId] ?? 0; - const rule = input.attributeRules?.[attributeId]; - const combined = rule?.combination === 'multiplicative_percent' - ? ((1 + current / 100) * (1 + value / 100) - 1) * 100 - : current + value; - attributes[attributeId] = rule?.cap === null || rule?.cap === undefined - ? combined - : Math.min(combined, rule.cap); - } - } - return attributes; + const attributes = { ...(input.attributes ?? {}) }; + for (const window of input.modifierWindows ?? []) { + const appliesToAbility = + !window.abilityIds?.length || window.abilityIds.includes(abilityId); + if ( + !appliesToAbility || + time < window.startSeconds || + time >= window.endSeconds + ) + continue; + for (const [attributeId, value] of Object.entries(window.attributes)) { + const current = attributes[attributeId] ?? 0; + const rule = input.attributeRules?.[attributeId]; + const combined = + rule?.combination === 'multiplicative_percent' + ? ((1 + current / 100) * (1 + value / 100) - 1) * 100 + : current + value; + attributes[attributeId] = + rule?.cap === null || rule?.cap === undefined + ? combined + : Math.min(combined, rule.cap); + } + } + return attributes; } function stageValues( - attributes: SkillStatValues, - bindings: SkillAttributeBinding[], - stage: SkillCalculationStage + attributes: SkillStatValues, + bindings: SkillAttributeBinding[], + stage: SkillCalculationStage, ) { - let additive = 0; - let multiplicative = 1; - for (const binding of bindings) { - if (binding.stage !== stage) continue; - const raw = Number(attributes[binding.attributeId] ?? 0); - const value = raw * (Number.isFinite(binding.weight) ? Number(binding.weight) : 1); - if (binding.operator === 'add') additive += value; - else multiplicative *= value; - } - return { additive, multiplicative }; + let additive = 0; + let multiplicative = 1; + for (const binding of bindings) { + if (binding.stage !== stage) continue; + const raw = Number(attributes[binding.attributeId] ?? 0); + const value = + raw * (Number.isFinite(binding.weight) ? Number(binding.weight) : 1); + if (binding.operator === 'add') additive += value; + else multiplicative *= value; + } + return { additive, multiplicative }; } function expectedDamage( - baseDamage: number, - attributes: SkillStatValues, - rules: SkillCalculationRules + baseDamage: number, + attributes: SkillStatValues, + rules: SkillCalculationRules, ) { - let damage = baseDamage; - const stageOrder = rules.stageOrder?.length - ? rules.stageOrder - : ['flat_damage', 'skill_damage', 'final_damage'] satisfies SkillCalculationStage[]; + let damage = baseDamage; + const stageOrder = rules.stageOrder?.length + ? rules.stageOrder + : ([ + 'flat_damage', + 'skill_damage', + 'final_damage', + ] satisfies SkillCalculationStage[]); - for (const stage of stageOrder) { - const values = stageValues(attributes, rules.bindings, stage); - if (stage === 'flat_damage') { - damage = (damage + values.additive) * values.multiplicative; - } else if (stage === 'skill_damage' || stage === 'final_damage') { - damage *= (1 + values.additive / 100) * values.multiplicative; - } - } + for (const stage of stageOrder) { + const values = stageValues(attributes, rules.bindings, stage); + if (stage === 'flat_damage') { + damage = (damage + values.additive) * values.multiplicative; + } else if (stage === 'skill_damage' || stage === 'final_damage') { + damage *= (1 + values.additive / 100) * values.multiplicative; + } + } - const criticalChance = stageValues(attributes, rules.bindings, 'critical_chance'); - const criticalDamage = stageValues(attributes, rules.bindings, 'critical_damage'); - const multishotChance = stageValues(attributes, rules.bindings, 'multishot_chance'); - const multishotDamage = stageValues(attributes, rules.bindings, 'multishot_damage'); - const criticalChanceRate = clamp( - criticalChance.additive * criticalChance.multiplicative / 100, - 0, - 1 - ); - const criticalDamageBonus = - criticalDamage.additive * criticalDamage.multiplicative / 100; - const multishotChanceRate = clamp( - multishotChance.additive * multishotChance.multiplicative / 100, - 0, - 1 - ); - const multishotDamageFactor = - multishotDamage.additive * multishotDamage.multiplicative / 100; + const criticalChance = stageValues( + attributes, + rules.bindings, + 'critical_chance', + ); + const criticalDamage = stageValues( + attributes, + rules.bindings, + 'critical_damage', + ); + const multishotChance = stageValues( + attributes, + rules.bindings, + 'multishot_chance', + ); + const multishotDamage = stageValues( + attributes, + rules.bindings, + 'multishot_damage', + ); + const criticalChanceRate = clamp( + (criticalChance.additive * criticalChance.multiplicative) / 100, + 0, + 1, + ); + const criticalDamageBonus = + (criticalDamage.additive * criticalDamage.multiplicative) / 100; + const multishotChanceRate = clamp( + (multishotChance.additive * multishotChance.multiplicative) / 100, + 0, + 1, + ); + const multishotDamageFactor = + (multishotDamage.additive * multishotDamage.multiplicative) / 100; - return damage - * (1 + criticalChanceRate * criticalDamageBonus) - * (1 + multishotChanceRate * multishotDamageFactor); + return ( + damage * + (1 + criticalChanceRate * criticalDamageBonus) * + (1 + multishotChanceRate * multishotDamageFactor) + ); } function zeroDamage(baseDamage = 0): SkillDamageSummary { - return { - unmodifiedBaseDamage: baseDamage, - averageExpectedDamagePerHit: 0, - singleTargetDamagePerActivation: 0, - aggregateDamagePerActivation: 0, - singleTargetEncounterDamage: 0, - aggregateEncounterDamage: 0, - singleTargetDps: 0, - aggregateDps: 0 - }; + return { + unmodifiedBaseDamage: baseDamage, + averageExpectedDamagePerHit: 0, + singleTargetDamagePerActivation: 0, + aggregateDamagePerActivation: 0, + singleTargetEncounterDamage: 0, + aggregateEncounterDamage: 0, + singleTargetDps: 0, + aggregateDps: 0, + }; } -export function calculateSkillDps(input: SkillCalculationInput): SkillCalculation { - const missing = requiredData(input); - if (missing.length) { - return { - status: 'INSUFFICIENT_DATA', - revisionId: input.rules?.revisionId ?? null, - missing, - abilities: [], - combined: null - }; - } +export function calculateSkillDps( + input: SkillCalculationInput, +): SkillCalculation { + const missing = requiredData(input); + if (missing.length) { + return { + status: 'INSUFFICIENT_DATA', + revisionId: input.rules?.revisionId ?? null, + missing, + abilities: [], + combined: null, + }; + } - const rules = input.rules!; - const encounterSeconds = Number(input.assumptions.encounterSeconds); - const targetCount = Number(input.assumptions.targetCount); - const abilities: SkillAbilityCalculation[] = input.abilities.map((ability) => { - if (ability.activation.mode === 'off') { - return { - abilityId: ability.id, - damageType: ability.damageType ?? '', - enabled: false, - activationCount: 0, - damageEventCount: 0, - effectiveTargets: 0, - damage: zeroDamage(Number(ability.baseDamage ?? 0)) - }; - } + const rules = input.rules!; + const encounterSeconds = Number(input.assumptions.encounterSeconds); + const targetCount = Number(input.assumptions.targetCount); + const abilities: SkillAbilityCalculation[] = input.abilities.map( + (ability) => { + if (ability.activation.mode === 'off') { + return { + abilityId: ability.id, + damageType: ability.damageType ?? '', + enabled: false, + activationCount: 0, + damageEventCount: 0, + effectiveTargets: 0, + damage: zeroDamage(Number(ability.baseDamage ?? 0)), + }; + } - const activations = skillActivationTimes(ability, encounterSeconds, input, rules); - const events = damageEvents(ability, activations, encounterSeconds, input, rules); - const damageByEvent = events.map((event) => - expectedDamage( - Number(ability.baseDamage), - attributesAt(input, ability.id, event.time), - rules - ) - ); - const singleTargetEncounterDamage = damageByEvent.reduce((total, damage) => total + damage, 0); - const effectiveTargets = Math.min( - Number(ability.targetCount ?? targetCount), - Number(ability.targetCap) - ); - const aggregateEncounterDamage = singleTargetEncounterDamage * effectiveTargets; - const deliveredActivations = new Set(events.map((event) => event.activationIndex)).size; - const averageExpectedDamagePerHit = damageByEvent.length - ? singleTargetEncounterDamage / damageByEvent.length - : 0; - const singleTargetDamagePerActivation = deliveredActivations - ? singleTargetEncounterDamage / deliveredActivations - : 0; + const activations = skillActivationTimes( + ability, + encounterSeconds, + input, + rules, + ); + const events = damageEvents( + ability, + activations, + encounterSeconds, + input, + rules, + ); + const damageByEvent = events.map((event) => + expectedDamage( + Number(ability.baseDamage), + attributesAt(input, ability.id, event.time), + rules, + ), + ); + const singleTargetEncounterDamage = damageByEvent.reduce( + (total, damage) => total + damage, + 0, + ); + const effectiveTargets = Math.min( + Number(ability.targetCount ?? targetCount), + Number(ability.targetCap), + ); + const aggregateEncounterDamage = + singleTargetEncounterDamage * effectiveTargets; + const deliveredActivations = new Set( + events.map((event) => event.activationIndex), + ).size; + const averageExpectedDamagePerHit = damageByEvent.length + ? singleTargetEncounterDamage / damageByEvent.length + : 0; + const singleTargetDamagePerActivation = deliveredActivations + ? singleTargetEncounterDamage / deliveredActivations + : 0; - return { - abilityId: ability.id, - damageType: ability.damageType!, - enabled: true, - activationCount: activations.length, - damageEventCount: events.length, - effectiveTargets, - damage: { - unmodifiedBaseDamage: Number(ability.baseDamage), - averageExpectedDamagePerHit, - singleTargetDamagePerActivation, - aggregateDamagePerActivation: singleTargetDamagePerActivation * effectiveTargets, - singleTargetEncounterDamage, - aggregateEncounterDamage, - singleTargetDps: singleTargetEncounterDamage / encounterSeconds, - aggregateDps: aggregateEncounterDamage / encounterSeconds - } - }; - }); + return { + abilityId: ability.id, + damageType: ability.damageType!, + enabled: true, + activationCount: activations.length, + damageEventCount: events.length, + effectiveTargets, + damage: { + unmodifiedBaseDamage: Number(ability.baseDamage), + averageExpectedDamagePerHit, + singleTargetDamagePerActivation, + aggregateDamagePerActivation: + singleTargetDamagePerActivation * effectiveTargets, + singleTargetEncounterDamage, + aggregateEncounterDamage, + singleTargetDps: singleTargetEncounterDamage / encounterSeconds, + aggregateDps: aggregateEncounterDamage / encounterSeconds, + }, + }; + }, + ); - const combined = abilities.reduce( - (total, ability) => ({ - singleTargetEncounterDamage: - total.singleTargetEncounterDamage + ability.damage.singleTargetEncounterDamage, - aggregateEncounterDamage: - total.aggregateEncounterDamage + ability.damage.aggregateEncounterDamage, - singleTargetDps: total.singleTargetDps + ability.damage.singleTargetDps, - aggregateDps: total.aggregateDps + ability.damage.aggregateDps - }), - { - singleTargetEncounterDamage: 0, - aggregateEncounterDamage: 0, - singleTargetDps: 0, - aggregateDps: 0 - } - ); + const combined = abilities.reduce( + (total, ability) => ({ + singleTargetEncounterDamage: + total.singleTargetEncounterDamage + + ability.damage.singleTargetEncounterDamage, + aggregateEncounterDamage: + total.aggregateEncounterDamage + + ability.damage.aggregateEncounterDamage, + singleTargetDps: total.singleTargetDps + ability.damage.singleTargetDps, + aggregateDps: total.aggregateDps + ability.damage.aggregateDps, + }), + { + singleTargetEncounterDamage: 0, + aggregateEncounterDamage: 0, + singleTargetDps: 0, + aggregateDps: 0, + }, + ); - return { - status: 'OK', - revisionId: rules.revisionId, - missing: [], - abilities, - combined - }; + return { + status: 'OK', + revisionId: rules.revisionId, + missing: [], + abilities, + combined, + }; } diff --git a/src/lib/calculations/talent-description.test.ts b/src/lib/calculations/talent-description.test.ts index 1674bed..9d5bf2a 100644 --- a/src/lib/calculations/talent-description.test.ts +++ b/src/lib/calculations/talent-description.test.ts @@ -1,102 +1,139 @@ import { describe, expect, it } from 'vitest'; import type { ConditionalMechanicDraftV1 } from './mechanic-editor'; -import { missingTalentDescriptionSlots, numberedTalentOutcomes } from './talent-description'; +import { + missingTalentDescriptionSlots, + numberedTalentOutcomes, +} from './talent-description'; -const draft = (outcomes: ConditionalMechanicDraftV1['clauses'][number]['outcomes']): ConditionalMechanicDraftV1 => ({ - version: 1, - simulationReadiness: 'ready', - scenarioInputs: [], - pools: [], - clauses: [{ - key: 'primary', - label: 'Primary', - event: 'enemy_killed', - if: { kind: 'true' }, - reducer: { kind: 'none' }, - mutations: [], - outcomes - }] +const draft = ( + outcomes: ConditionalMechanicDraftV1['clauses'][number]['outcomes'], +): ConditionalMechanicDraftV1 => ({ + version: 1, + simulationReadiness: 'ready', + scenarioInputs: [], + pools: [], + clauses: [ + { + key: 'primary', + label: 'Primary', + event: 'enemy_killed', + if: { kind: 'true' }, + reducer: { kind: 'none' }, + mutations: [], + outcomes, + }, + ], }); describe('talent description outcomes', () => { - it('accepts a numbered restoration outcome as a description value', () => { - const outcomes = numberedTalentOutcomes(draft([{ - key: 'restore-health', - label: 'Restore Health', - phase: 'after_event', - kind: 'restoration', - scope: 'self', - resource: 'health', - delivery: 'continuous', - durationSeconds: 3, - basis: 'max_health_percent', - descriptionSlot: 1, - variants: { - I: { value: 240 }, - II: { value: 336 } - } - }])); + it('accepts a numbered restoration outcome as a description value', () => { + const outcomes = numberedTalentOutcomes( + draft([ + { + key: 'restore-health', + label: 'Restore Health', + phase: 'after_event', + kind: 'restoration', + scope: 'self', + resource: 'health', + delivery: 'continuous', + durationSeconds: 3, + basis: 'max_health_percent', + descriptionSlot: 1, + variants: { + I: { value: 240 }, + II: { value: 336 }, + }, + }, + ]), + ); - expect(outcomes).toHaveLength(1); - expect(outcomes[0]).toMatchObject({ kind: 'restoration', descriptionSlot: 1 }); - expect(missingTalentDescriptionSlots('Restore {1} over time.', outcomes)).toEqual([]); - }); + expect(outcomes).toHaveLength(1); + expect(outcomes[0]).toMatchObject({ + kind: 'restoration', + descriptionSlot: 1, + }); + expect( + missingTalentDescriptionSlots('Restore {1} over time.', outcomes), + ).toEqual([]); + }); - it('accepts a numbered marker outcome for a structured timing value', () => { - const outcomes = numberedTalentOutcomes(draft([{ - key: 'delayed-reload', - label: 'Delayed reload', - phase: 'after_event', - kind: 'marker', - scope: 'equipped_weapon', - basis: 'flat', - descriptionSlot: 1, - variants: { - I: { value: 4 }, - II: { value: 3 } - } - }])); + it('accepts a numbered marker outcome for a structured timing value', () => { + const outcomes = numberedTalentOutcomes( + draft([ + { + key: 'delayed-reload', + label: 'Delayed reload', + phase: 'after_event', + kind: 'marker', + scope: 'equipped_weapon', + basis: 'flat', + descriptionSlot: 1, + variants: { + I: { value: 4 }, + II: { value: 3 }, + }, + }, + ]), + ); - expect(outcomes).toHaveLength(1); - expect(outcomes[0]).toMatchObject({ kind: 'marker', descriptionSlot: 1 }); - expect(missingTalentDescriptionSlots('Reload after {1} seconds.', outcomes)).toEqual([]); - }); + expect(outcomes).toHaveLength(1); + expect(outcomes[0]).toMatchObject({ kind: 'marker', descriptionSlot: 1 }); + expect( + missingTalentDescriptionSlots('Reload after {1} seconds.', outcomes), + ).toEqual([]); + }); - it('accepts a numbered weapon-reload delay', () => { - const outcomes = numberedTalentOutcomes(draft([{ - key: 'inactive-reload', - label: 'Inactive weapon reload', - phase: 'after_event', - kind: 'weapon_reload', - scope: 'inactive_weapon', - basis: 'flat', - descriptionSlot: 1, - variants: { - I: { value: 4 }, - II: { value: 3 } - } - }])); + it('accepts a numbered weapon-reload delay', () => { + const outcomes = numberedTalentOutcomes( + draft([ + { + key: 'inactive-reload', + label: 'Inactive weapon reload', + phase: 'after_event', + kind: 'weapon_reload', + scope: 'inactive_weapon', + basis: 'flat', + descriptionSlot: 1, + variants: { + I: { value: 4 }, + II: { value: 3 }, + }, + }, + ]), + ); - expect(outcomes).toHaveLength(1); - expect(outcomes[0]).toMatchObject({ kind: 'weapon_reload', descriptionSlot: 1 }); - expect(missingTalentDescriptionSlots('Reload after {1} seconds.', outcomes)).toEqual([]); - }); + expect(outcomes).toHaveLength(1); + expect(outcomes[0]).toMatchObject({ + kind: 'weapon_reload', + descriptionSlot: 1, + }); + expect( + missingTalentDescriptionSlots('Reload after {1} seconds.', outcomes), + ).toEqual([]); + }); - it('reports exact missing slots instead of relying on outcome count', () => { - const outcomes = numberedTalentOutcomes(draft([{ - key: 'damage', - label: 'Damage', - phase: 'on_event', - kind: 'direct_damage', - scope: 'target', - basis: 'flat', - descriptionSlot: 2, - variants: { - I: { value: 10 }, - II: { value: 14 } - } - }])); + it('reports exact missing slots instead of relying on outcome count', () => { + const outcomes = numberedTalentOutcomes( + draft([ + { + key: 'damage', + label: 'Damage', + phase: 'on_event', + kind: 'direct_damage', + scope: 'target', + basis: 'flat', + descriptionSlot: 2, + variants: { + I: { value: 10 }, + II: { value: 14 }, + }, + }, + ]), + ); - expect(missingTalentDescriptionSlots('{1} then {2} and {3}.', outcomes)).toEqual([1, 3]); - }); + expect( + missingTalentDescriptionSlots('{1} then {2} and {3}.', outcomes), + ).toEqual([1, 3]); + }); }); diff --git a/src/lib/calculations/talent-description.ts b/src/lib/calculations/talent-description.ts index bf89f75..af8b199 100644 --- a/src/lib/calculations/talent-description.ts +++ b/src/lib/calculations/talent-description.ts @@ -1,40 +1,52 @@ import type { - ConditionalMechanicDraftV1, - ConditionalMechanicOutcomeDraft + ConditionalMechanicDraftV1, + ConditionalMechanicOutcomeDraft, } from '$lib/calculations/mechanic-editor'; const VALUE_BEARING_OUTCOME_KINDS = new Set([ - 'attribute_modifier', - 'direct_damage', - 'restoration', - 'resource_cost', - 'weapon_reload', - 'marker' + 'attribute_modifier', + 'direct_damage', + 'restoration', + 'resource_cost', + 'weapon_reload', + 'marker', ]); export const numberedTalentOutcomes = ( - draft: ConditionalMechanicDraftV1 + draft: ConditionalMechanicDraftV1, ): ConditionalMechanicOutcomeDraft[] => { - const outcomes = draft.clauses - .flatMap((clause) => clause.outcomes) - .filter((outcome) => - VALUE_BEARING_OUTCOME_KINDS.has(outcome.kind) - && Number.isInteger(outcome.descriptionSlot) - && Number(outcome.descriptionSlot) > 0 - ) - .sort((left, right) => Number(left.descriptionSlot) - Number(right.descriptionSlot)); + const outcomes = draft.clauses + .flatMap((clause) => clause.outcomes) + .filter( + (outcome) => + VALUE_BEARING_OUTCOME_KINDS.has(outcome.kind) && + Number.isInteger(outcome.descriptionSlot) && + Number(outcome.descriptionSlot) > 0, + ) + .sort( + (left, right) => + Number(left.descriptionSlot) - Number(right.descriptionSlot), + ); - return [...new Map(outcomes.map((outcome) => [outcome.descriptionSlot, outcome])).values()]; + return [ + ...new Map( + outcomes.map((outcome) => [outcome.descriptionSlot, outcome]), + ).values(), + ]; }; export const missingTalentDescriptionSlots = ( - template: string, - outcomes: ConditionalMechanicOutcomeDraft[] + template: string, + outcomes: ConditionalMechanicOutcomeDraft[], ) => { - const availableSlots = new Set(outcomes.map((outcome) => Number(outcome.descriptionSlot))); - return [...new Set( - [...template.matchAll(/\{(\d+)\}/g)] - .map((match) => Number(match[1])) - .filter((slot) => !availableSlots.has(slot)) - )]; + const availableSlots = new Set( + outcomes.map((outcome) => Number(outcome.descriptionSlot)), + ); + return [ + ...new Set( + [...template.matchAll(/\{(\d+)\}/g)] + .map((match) => Number(match[1])) + .filter((slot) => !availableSlots.has(slot)), + ), + ]; }; diff --git a/src/lib/components/ArticleCard.svelte b/src/lib/components/ArticleCard.svelte new file mode 100644 index 0000000..b054adc --- /dev/null +++ b/src/lib/components/ArticleCard.svelte @@ -0,0 +1,17 @@ + +
+ + {#if article.coverUrl}{:else}
SHD // {kind === 'guide' ? 'FIELD MANUAL' : 'TRANSMISSION'}{article.tags?.[0]?.name ?? article.channel ?? 'COMMUNITY INTELLIGENCE'}
{/if} +
+
+
{article.channel ?? article.tags?.[0]?.name ?? kind.toUpperCase()}{#if article.recommended}RECOMMENDED{/if}{#if article.featured}FEATURED{/if}
+

{article.title}

+

{article.deck}

+
BY {(article.source?.name ?? article.author).toUpperCase()} · {new Date(article.publishedAt).toLocaleDateString()}{#if kind === 'guide'}{article.votes ? `★ ${article.rating.toFixed(1)} · ${article.votes}` : 'UNRATED'}{/if}
+
+
+ diff --git a/src/lib/components/ArticleComposer.svelte b/src/lib/components/ArticleComposer.svelte new file mode 100644 index 0000000..f180755 --- /dev/null +++ b/src/lib/components/ArticleComposer.svelte @@ -0,0 +1,143 @@ + + +
+
+

{data.article.kind === 'guide' ? 'GUIDE COMPOSER' : 'NEWSROOM'}

{data.article.kind === 'guide' ? 'AUTHOR FIELD INTELLIGENCE' : 'PREPARE A TRANSMISSION'}

+
{saveState === 'saving' ? 'SAVING DRAFT' : saveState === 'saved' ? 'DRAFT SAVED' : saveState === 'conflict' ? 'DRAFT CONFLICT · RELOAD' : 'SAVE FAILED'}
+
+ {#if form?.articleError}{/if} + {#if form?.articleMessage}

{form.articleMessage}

{/if} + +
+ + +
+ +
+
PUBLICATION{data.article.kind.toUpperCase()}
+ visibility = values[0]}/> + {#if data.article.kind === 'news'}{/if} +

{visibility === 'private' ? 'Only you can open this draft.' : visibility === 'unlisted' ? 'Anyone with the direct URL can read it.' : 'Listed in discovery and visible to everyone.'}

{#if data.article.pendingRevisionId}MODERATION REVIEW PENDING{/if}
+ + + + +
+ +
+
+ +
+ +
+
+ + diff --git a/src/lib/components/ArticleContent.svelte b/src/lib/components/ArticleContent.svelte new file mode 100644 index 0000000..6fa6d32 --- /dev/null +++ b/src/lib/components/ArticleContent.svelte @@ -0,0 +1,66 @@ + + +{#snippet renderText(text: string, marks: ArticleMark[], index = 0)} + {#if index >= marks.length}{text}{:else} + {@const mark = marks[index]} + {#if mark.type === 'bold'}{@render renderText(text, marks, index + 1)} + {:else if mark.type === 'italic'}{@render renderText(text, marks, index + 1)} + {:else if mark.type === 'code'}{@render renderText(text, marks, index + 1)} + {:else}{@render renderText(text, marks, index + 1)}{/if} + {/if} +{/snippet} + +{#snippet children(node: ArticleNode)} + {#each node.content ?? [] as child}{@render renderNode(child)}{/each} +{/snippet} + +{#snippet renderNode(node: ArticleNode)} + {#if node.type === 'text'}{@render renderText(node.text ?? '', node.marks ?? [])} + {:else if node.type === 'paragraph'}

{@render children(node)}

+ {:else if node.type === 'heading' && Number(node.attrs?.level) === 2}

{@render children(node)}

+ {:else if node.type === 'heading'}

{@render children(node)}

+ {:else if node.type === 'bulletList'}
    {@render children(node)}
+ {:else if node.type === 'orderedList'}
    {@render children(node)}
+ {:else if node.type === 'listItem'}
  • {@render children(node)}
  • + {:else if node.type === 'blockquote'}
    {@render children(node)}
    + {:else if node.type === 'horizontalRule'}
    + {:else if node.type === 'hardBreak'}
    + {:else if node.type === 'buildEmbed' && builds[String(node.attrs?.embedKey ?? '')]} + + {:else if node.type === 'articleImage' && images[String(node.attrs?.imageId ?? '')]} + {@const image = images[String(node.attrs?.imageId ?? '')]} +
    {image.alt}/{#if image.caption}
    {image.caption}
    {/if}
    + {/if} +{/snippet} + +
    {@render children(document)}
    + + diff --git a/src/lib/components/ArticleDiscussion.svelte b/src/lib/components/ArticleDiscussion.svelte new file mode 100644 index 0000000..fb16968 --- /dev/null +++ b/src/lib/components/ArticleDiscussion.svelte @@ -0,0 +1,30 @@ + +
    +
    COMMUNITY DISCUSSION

    FIELD NOTES & QUESTIONS

    {comments.filter((item:any)=>item.status==='visible').length} COMMENTS
    + {#if viewer} + {#if form?.commentError}

    {form.commentError}

    {/if}{#if form?.commentMessage}

    {form.commentMessage}

    {/if}
    + {:else}{/if} +
    + {#each comments as comment} +
    0} style={`--depth:${comment.depth}`}> + {#if comment.status === 'visible'} +
    + +
    {#if viewer}{/if}{#if comment.canReport}{/if}{#if comment.canDelete}{/if}
    + {#if replyingTo===comment.id}{/if} + {:else}
    COMMENT REMOVEDReplies remain visible to preserve the conversation.
    {/if} +
    + {:else}
    NO COMMENTS YETStart the discussion with a useful question or field observation.
    {/each} +
    +
    + diff --git a/src/lib/components/ArticleEditor.svelte b/src/lib/components/ArticleEditor.svelte new file mode 100644 index 0000000..378d9cc --- /dev/null +++ b/src/lib/components/ArticleEditor.svelte @@ -0,0 +1,188 @@ + + +
    +
    + + + + + + + + + + + + + + + + +
    + {#if showBuilds} +
    + +
    + {#each filteredBuilds as build} + + {:else}

    No eligible Builds match this search.

    {/each} +
    +
    + {/if} + {#if uploadOpen} +
    + + + + {#if uploadError}{/if} + +
    + {/if} +
    + +
    + + diff --git a/src/lib/components/ArticlePage.svelte b/src/lib/components/ArticlePage.svelte new file mode 100644 index 0000000..4db22ac --- /dev/null +++ b/src/lib/components/ArticlePage.svelte @@ -0,0 +1,48 @@ + + + {data.article.title} — SHD.build + + + {#if data.article.coverUrl}{/if} + {#if data.article.source}{/if} + {#if data.turnstileSiteKey && form?.articleChallengeRequired}{/if} + +
    +
    +
    {data.article.channel ?? (data.article.kind === 'guide' ? 'COMMUNITY GUIDE' : 'NEWS')}{#if data.article.featured}FEATURED{/if}{#if data.article.recommended}RECOMMENDED{/if}
    +

    {data.article.title}

    {data.article.deck}

    + + {#if data.article.source}{/if} + {#if data.tags.length}{/if} +
    + {#if data.article.coverUrl}
    {data.article.coverAlt}/{#if data.article.coverCaption}
    {data.article.coverCaption}
    {/if}
    {/if} +
    +
    + {#if data.article.kind === 'guide'}{/if} +
    + +
    + diff --git a/src/lib/components/BuildEmbed.svelte b/src/lib/components/BuildEmbed.svelte new file mode 100644 index 0000000..cc00006 --- /dev/null +++ b/src/lib/components/BuildEmbed.svelte @@ -0,0 +1,40 @@ + + + + + diff --git a/src/lib/components/ContentReport.svelte b/src/lib/components/ContentReport.svelte index 4e1bf39..20488d0 100644 --- a/src/lib/components/ContentReport.svelte +++ b/src/lib/components/ContentReport.svelte @@ -7,7 +7,8 @@ label = 'REPORT', result, plain = false, - menuItem = false + menuItem = false, + itemId = '' }: { contentType: 'comment' | 'profile_bio' | 'guide' | 'avatar'; contentId: string; @@ -15,6 +16,7 @@ result?: Record | null; plain?: boolean; menuItem?: boolean; + itemId?: string; } = $props(); const categories = [ @@ -48,6 +50,7 @@ {#snippet children(pending)} + {#if itemId}{/if}