feat: add community publishing and news syndication
This commit is contained in:
@@ -39,3 +39,7 @@ vite.config.ts.timestamp-*
|
||||
|
||||
_img/
|
||||
tmp/
|
||||
|
||||
# Python caches
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
npx lint-staged
|
||||
@@ -8,12 +8,13 @@ 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. |
|
||||
@@ -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
|
||||
|
||||
|
||||
+220
-22
@@ -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": {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Ignore artifacts:
|
||||
build
|
||||
coverage
|
||||
tmp
|
||||
_img
|
||||
+17
@@ -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
|
||||
}
|
||||
+13
-12
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
+225
-7
@@ -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),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
+43
-26
@@ -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,8 +114,9 @@ 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
|
||||
@@ -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
|
||||
|
||||
+102
-51
@@ -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=<public site key>
|
||||
TURNSTILE_SECRET_KEY=<server verification secret>
|
||||
```
|
||||
|
||||
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 -- <article-id-or-slug>
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
+925
-841
File diff suppressed because it is too large
Load Diff
+289
-207
@@ -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=<id>`, 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/<backup-file>.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
|
||||
|
||||
Generated
+977
-123
File diff suppressed because it is too large
Load Diff
+13
-1
@@ -21,6 +21,7 @@
|
||||
"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",
|
||||
@@ -49,11 +50,14 @@
|
||||
"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 ''",
|
||||
"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",
|
||||
@@ -61,7 +65,10 @@
|
||||
"@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",
|
||||
@@ -73,6 +80,11 @@
|
||||
"@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",
|
||||
|
||||
@@ -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 <article-id-or-slug> [output.json]',
|
||||
);
|
||||
console.error(
|
||||
` or: ARTICLE_IMPORT_ENABLED=true node scripts/article-transfer.mjs import <bundle.json> ${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();
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
|
||||
+15
-7
@@ -3,14 +3,20 @@ 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);
|
||||
@@ -28,7 +34,9 @@ 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();
|
||||
const rows = await (
|
||||
await database.prepare(`SELECT * FROM ${quoteIdentifier(name)}`)
|
||||
).all();
|
||||
tables.push({ name, rowCount: rows.length, rows });
|
||||
totalRows += rows.length;
|
||||
}
|
||||
@@ -39,11 +47,11 @@ const backup = {
|
||||
createdAt: new Date().toISOString(),
|
||||
sourceDatabase: basename(databasePath),
|
||||
totalRows,
|
||||
tables
|
||||
tables,
|
||||
};
|
||||
await writeFile(outputPath, `${JSON.stringify(backup, null, 2)}\n`, {
|
||||
encoding: 'utf8',
|
||||
flag: 'wx'
|
||||
flag: 'wx',
|
||||
});
|
||||
|
||||
console.log(`Backed up ${totalRows} rows from ${tables.length} tables.`);
|
||||
|
||||
+1426
-484
File diff suppressed because it is too large
Load Diff
+18
-7
@@ -10,24 +10,35 @@ if (!backupArgument) {
|
||||
}
|
||||
|
||||
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)}`)
|
||||
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.`
|
||||
`Restore refused: table "${table.name}" is not empty. Use a new migrated database.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -42,7 +53,7 @@ try {
|
||||
const columnSql = columns.map(quoteIdentifier).join(', ');
|
||||
const placeholders = columns.map(() => '?').join(', ');
|
||||
const insert = await database.prepare(
|
||||
`INSERT INTO ${quoteIdentifier(table.name)} (${columnSql}) VALUES (${placeholders})`
|
||||
`INSERT INTO ${quoteIdentifier(table.name)} (${columnSql}) VALUES (${placeholders})`,
|
||||
);
|
||||
await insert.run(...columns.map((column) => row[column]));
|
||||
restoredRows++;
|
||||
|
||||
+33
-16
@@ -5,13 +5,13 @@ export const defaultUsers = [
|
||||
{
|
||||
username: 'Aewyn',
|
||||
role: 'user',
|
||||
passwordEnvironmentKey: 'DEFAULT_ADMIN_PASSWORD'
|
||||
passwordEnvironmentKey: 'DEFAULT_ADMIN_PASSWORD',
|
||||
},
|
||||
{
|
||||
username: 'Quartermaster',
|
||||
role: 'user',
|
||||
passwordEnvironmentKey: 'DEFAULT_USER_PASSWORD'
|
||||
}
|
||||
passwordEnvironmentKey: 'DEFAULT_USER_PASSWORD',
|
||||
},
|
||||
];
|
||||
|
||||
function temporaryPassword() {
|
||||
@@ -24,7 +24,7 @@ async function secureHash(password) {
|
||||
type: argon2id,
|
||||
memoryCost: 19_456,
|
||||
timeCost: 2,
|
||||
parallelism: 1
|
||||
parallelism: 1,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -35,33 +35,48 @@ async function secureHash(password) {
|
||||
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'
|
||||
'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 (?, ?, ?, ?, ?, ?)'
|
||||
'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 = ?'
|
||||
'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);
|
||||
const existing = await findUser.get(
|
||||
definition.username,
|
||||
definition.username,
|
||||
);
|
||||
if (existing && !resetPasswords) {
|
||||
console.log(`${definition.username}: already exists (${existing.role}); left unchanged`);
|
||||
console.log(
|
||||
`${definition.username}: already exists (${existing.role}); left unchanged`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const configuredPassword = process.env[definition.passwordEnvironmentKey]?.trim();
|
||||
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.`);
|
||||
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`);
|
||||
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(),
|
||||
@@ -69,7 +84,7 @@ export async function initializeDefaultUsers(database, options = {}) {
|
||||
definition.username,
|
||||
passwordHash,
|
||||
definition.role,
|
||||
new Date().toISOString()
|
||||
new Date().toISOString(),
|
||||
);
|
||||
console.log(`${definition.username}: default account created`);
|
||||
}
|
||||
@@ -77,14 +92,16 @@ export async function initializeDefaultUsers(database, options = {}) {
|
||||
username: definition.username,
|
||||
role: existing ? String(existing.role) : definition.role,
|
||||
password,
|
||||
generated: !configuredPassword
|
||||
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(
|
||||
`${credential.username} (${credential.role}): ${credential.password}${credential.generated ? ' [generated]' : ' [from environment]'}`,
|
||||
);
|
||||
}
|
||||
console.log('Change generated passwords after the first sign-in.\n');
|
||||
}
|
||||
|
||||
@@ -8,7 +8,10 @@ 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' });
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -5,28 +5,48 @@ import process from 'node:process';
|
||||
|
||||
const target = process.argv[2];
|
||||
if (!['staging', 'production'].includes(target)) {
|
||||
console.error('Usage: node scripts/environment-bootstrap-export.mjs <staging|production> [output.json]');
|
||||
console.error(
|
||||
'Usage: node scripts/environment-bootstrap-export.mjs <staging|production> [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], {
|
||||
const databaseBackup = spawnSync(
|
||||
process.execPath,
|
||||
['scripts/db-backup.mjs', temporaryBackupPath],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
stdio: 'inherit',
|
||||
env: process.env
|
||||
});
|
||||
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) => ({
|
||||
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')
|
||||
})));
|
||||
contentBase64: (await readFile(join(directory, name))).toString(
|
||||
'base64',
|
||||
),
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return [];
|
||||
throw error;
|
||||
@@ -35,19 +55,30 @@ async function collectFiles(directory, pattern) {
|
||||
|
||||
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 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 }
|
||||
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.`);
|
||||
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 });
|
||||
|
||||
@@ -4,75 +4,127 @@ 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'
|
||||
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 <bundle.json> --target <staging|production> --confirm-empty-target-replace');
|
||||
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 <bundle.json> --target <staging|production> --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') {
|
||||
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
|
||||
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();
|
||||
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.`);
|
||||
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)}".`);
|
||||
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));
|
||||
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');
|
||||
@@ -86,7 +138,7 @@ try {
|
||||
if (!columns.length) continue;
|
||||
const insert = await database.prepare(
|
||||
`INSERT INTO ${quoteIdentifier(table.name)} (${columns.map(quoteIdentifier).join(', ')})
|
||||
VALUES (${columns.map(() => '?').join(', ')})`
|
||||
VALUES (${columns.map(() => '?').join(', ')})`,
|
||||
);
|
||||
await insert.run(...columns.map((column) => row[column]));
|
||||
restoredRows++;
|
||||
@@ -100,16 +152,30 @@ try {
|
||||
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}`);
|
||||
|
||||
@@ -4,41 +4,69 @@ 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'})`);
|
||||
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}`);
|
||||
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]) {
|
||||
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 { 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 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;
|
||||
@@ -48,9 +76,11 @@ 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 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 = {
|
||||
@@ -73,33 +103,66 @@ try {
|
||||
cap_value: existing?.cap_value ?? null,
|
||||
active: 1,
|
||||
created_at: String(existing?.created_at ?? now),
|
||||
updated_at: now
|
||||
updated_at: now,
|
||||
};
|
||||
const comparableExisting = existing ? { ...existing, updated_at: now } : null;
|
||||
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
|
||||
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
|
||||
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(
|
||||
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
|
||||
) 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++;
|
||||
}
|
||||
@@ -109,7 +172,7 @@ try {
|
||||
action: existing ? 'attribute.updated' : 'attribute.created',
|
||||
entityId: id,
|
||||
before,
|
||||
after: await auditRecord('stat_attributes', id)
|
||||
after: await auditRecord('stat_attributes', id),
|
||||
});
|
||||
}
|
||||
await database.exec('COMMIT');
|
||||
@@ -117,7 +180,9 @@ try {
|
||||
await database.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
console.log(`Imported ${records.length} Attribute definitions: ${created} created, ${updated} updated, ${unchanged} unchanged.`);
|
||||
console.log(
|
||||
`Imported ${records.length} Attribute definitions: ${created} created, ${updated} updated, ${unchanged} unchanged.`,
|
||||
);
|
||||
console.log(`Source: ${sourcePath}`);
|
||||
} finally {
|
||||
await vite.close();
|
||||
|
||||
@@ -4,20 +4,36 @@ 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();
|
||||
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;
|
||||
}
|
||||
@@ -25,15 +41,28 @@ for (const name of ISAC_DATA_TABLES) {
|
||||
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)}".`);
|
||||
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') });
|
||||
icons.push({
|
||||
storageKey: row.storage_key,
|
||||
contentBase64: content.toString('base64'),
|
||||
});
|
||||
}
|
||||
|
||||
const snapshot = {
|
||||
@@ -43,10 +72,15 @@ const snapshot = {
|
||||
sourceDatabase: basename(databasePath),
|
||||
totalRows,
|
||||
tables,
|
||||
icons
|
||||
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}`);
|
||||
|
||||
@@ -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 <snapshot.json> --confirm-replace-isac-data');
|
||||
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 <snapshot.json> --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') {
|
||||
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/isac-data-export.mjs', isacBackupPath],
|
||||
]) {
|
||||
const result = spawnSync(process.execPath, [script, output], { cwd: process.cwd(), stdio: 'inherit', env: process.env });
|
||||
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.`);
|
||||
if (result.status !== 0)
|
||||
throw new Error(
|
||||
`Safety backup failed in ${script}; no ISAC data was changed.`,
|
||||
);
|
||||
}
|
||||
|
||||
const database = await connect(databasePath);
|
||||
@@ -54,7 +92,7 @@ try {
|
||||
if (!columns.length) continue;
|
||||
const insert = await database.prepare(
|
||||
`INSERT INTO ${quoteIdentifier(table.name)} (${columns.map(quoteIdentifier).join(', ')})
|
||||
VALUES (${columns.map(() => '?').join(', ')})`
|
||||
VALUES (${columns.map(() => '?').join(', ')})`,
|
||||
);
|
||||
await insert.run(...columns.map((column) => row[column]));
|
||||
importedRows++;
|
||||
@@ -68,12 +106,20 @@ try {
|
||||
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}`);
|
||||
|
||||
@@ -70,7 +70,8 @@ export const ISAC_DATA_TABLES = [
|
||||
'skill_attribute_calculation_profiles',
|
||||
'calculation_calibration_cases',
|
||||
'game_data_icons',
|
||||
'managed_images'
|
||||
'managed_images',
|
||||
];
|
||||
|
||||
export const quoteIdentifier = (identifier) => `"${String(identifier).replaceAll('"', '""')}"`;
|
||||
export const quoteIdentifier = (identifier) =>
|
||||
`"${String(identifier).replaceAll('"', '""')}"`;
|
||||
|
||||
@@ -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)}".`);
|
||||
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') });
|
||||
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
|
||||
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}`);
|
||||
|
||||
@@ -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 <bundle.json> --confirm-merge [--update-existing]');
|
||||
console.error(
|
||||
'Usage: node scripts/managed-images-import.mjs <bundle.json> --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') {
|
||||
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
|
||||
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.');
|
||||
: !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;
|
||||
@@ -54,63 +91,139 @@ 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);
|
||||
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.`);
|
||||
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.`);
|
||||
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.`);
|
||||
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) {
|
||||
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'
|
||||
});
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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,
|
||||
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()
|
||||
existing ? JSON.stringify(existing) : null,
|
||||
JSON.stringify(row),
|
||||
new Date().toISOString(),
|
||||
);
|
||||
}
|
||||
await database.exec('COMMIT');
|
||||
}
|
||||
await database.exec('COMMIT');
|
||||
} catch (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.`,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
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
|
||||
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);
|
||||
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) => `<div style="border-top:1px solid #34393a;padding:16px 0"><strong>${escape(item.title)}</strong><p style="color:#a5aaab">${escape(item.body)}</p>${item.link ? `<a style="color:#f6a821" href="${origin}${item.link}">VIEW INTELLIGENCE</a>` : ''}</div>`).join('');
|
||||
const escape = (value) =>
|
||||
String(value).replace(
|
||||
/[&<>"']/g,
|
||||
(character) =>
|
||||
({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
})[character],
|
||||
);
|
||||
const rows = items
|
||||
.map(
|
||||
(item) =>
|
||||
`<div style="border-top:1px solid #34393a;padding:16px 0"><strong>${escape(item.title)}</strong><p style="color:#a5aaab">${escape(item.body)}</p>${item.link ? `<a style="color:#f6a821" href="${origin}${item.link}">VIEW INTELLIGENCE</a>` : ''}</div>`,
|
||||
)
|
||||
.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: `<div style="background:#0a0c0d;color:#ecebe5;padding:28px;font-family:Arial,sans-serif"><p style="color:#f6a821;font-size:12px;letter-spacing:2px">SHD DAILY INTELLIGENCE</p><h1>${items.length} notification${items.length === 1 ? '' : 's'}</h1>${rows}</div>`
|
||||
text: items
|
||||
.map(
|
||||
(item) =>
|
||||
`${item.title}\n${item.body}\n${item.link ? `${origin}${item.link}` : ''}`,
|
||||
)
|
||||
.join('\n\n'),
|
||||
html: `<div style="background:#0a0c0d;color:#ecebe5;padding:28px;font-family:Arial,sans-serif"><p style="color:#f6a821;font-size:12px;letter-spacing:2px">SHD DAILY INTELLIGENCE</p><h1>${items.length} notification${items.length === 1 ? '' : 's'}</h1>${rows}</div>`,
|
||||
});
|
||||
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);
|
||||
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'}.`);
|
||||
|
||||
@@ -3,10 +3,14 @@ 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}`;
|
||||
@@ -15,39 +19,59 @@ for (const item of due) {
|
||||
}
|
||||
|
||||
const smtpConfigured = Boolean(process.env.SMTP_HOST && process.env.SMTP_FROM);
|
||||
const transport = smtpConfigured ? nodemailer.createTransport({
|
||||
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,
|
||||
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(/\/+$/, '');
|
||||
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 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 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
|
||||
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
|
||||
);
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
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({
|
||||
@@ -55,15 +79,24 @@ for (const items of groups.values()) {
|
||||
to: identity.email,
|
||||
subject: `SHD.build · ${title}`,
|
||||
text: `${body}\n\n${origin}${first.link}`,
|
||||
html: `<div style="background:#0a0c0d;color:#ecebe5;padding:28px;font-family:Arial,sans-serif"><p style="color:#f6a821;font-size:12px;letter-spacing:2px">ISAC OVERSIGHT NETWORK</p><h1>${title}</h1><p style="white-space:pre-line;color:#a5aaab">${body}</p><a style="color:#f6a821" href="${origin}${first.link}">OPEN OVERSIGHT QUEUE</a></div>`
|
||||
html: `<div style="background:#0a0c0d;color:#ecebe5;padding:28px;font-family:Arial,sans-serif"><p style="color:#f6a821;font-size:12px;letter-spacing:2px">ISAC OVERSIGHT NETWORK</p><h1>${title}</h1><p style="white-space:pre-line;color:#a5aaab">${body}</p><a style="color:#f6a821" href="${origin}${first.link}">OPEN OVERSIGHT QUEUE</a></div>`,
|
||||
});
|
||||
await (await database.prepare('UPDATE notifications SET emailed_at=? WHERE id=?')).run(now, notificationId);
|
||||
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);
|
||||
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'}.`,
|
||||
);
|
||||
|
||||
@@ -6,9 +6,21 @@ 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 });
|
||||
|
||||
@@ -18,7 +30,7 @@ const colors = {
|
||||
border: '#303536',
|
||||
grid: '#2a3031',
|
||||
orange: '#f6a821',
|
||||
white: '#ecebe5'
|
||||
white: '#ecebe5',
|
||||
};
|
||||
|
||||
function trackedPath(font, value, x, baseline, size, tracking = 0) {
|
||||
@@ -27,7 +39,7 @@ function trackedPath(font, value, x, baseline, size, tracking = 0) {
|
||||
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;
|
||||
cursor += (glyph.advanceWidth * size) / font.unitsPerEm + tracking;
|
||||
}
|
||||
return { data, width: cursor - x - tracking };
|
||||
}
|
||||
@@ -154,8 +166,15 @@ 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'))
|
||||
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'));
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
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}` }
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!response.ok) throw new Error(`Scheduler endpoint returned HTTP ${response.status}.`);
|
||||
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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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}.`,
|
||||
);
|
||||
|
||||
@@ -4,24 +4,42 @@ 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], {
|
||||
const databaseBackup = spawnSync(
|
||||
process.execPath,
|
||||
['scripts/db-backup.mjs', temporaryBackupPath],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
stdio: 'inherit',
|
||||
env: process.env
|
||||
});
|
||||
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) => ({
|
||||
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')
|
||||
})));
|
||||
contentBase64: (await readFile(join(directory, name))).toString(
|
||||
'base64',
|
||||
),
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return [];
|
||||
throw error;
|
||||
@@ -32,22 +50,27 @@ 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
|
||||
/^[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
|
||||
/^[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 }
|
||||
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.`);
|
||||
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 });
|
||||
|
||||
@@ -4,69 +4,114 @@ 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') {
|
||||
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 <bundle.json> --confirm-empty-staging-replace');
|
||||
console.error(
|
||||
'node scripts/stage-bootstrap-import.mjs <bundle.json> --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') {
|
||||
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
|
||||
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();
|
||||
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.`);
|
||||
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)}".`);
|
||||
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));
|
||||
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');
|
||||
@@ -80,7 +125,7 @@ try {
|
||||
if (!columns.length) continue;
|
||||
const insert = await database.prepare(
|
||||
`INSERT INTO ${quoteIdentifier(table.name)} (${columns.map(quoteIdentifier).join(', ')})
|
||||
VALUES (${columns.map(() => '?').join(', ')})`
|
||||
VALUES (${columns.map(() => '?').join(', ')})`,
|
||||
);
|
||||
await insert.run(...columns.map((column) => row[column]));
|
||||
restoredRows++;
|
||||
@@ -94,16 +139,30 @@ try {
|
||||
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}`);
|
||||
|
||||
@@ -13,18 +13,23 @@ const sharedKeys = [
|
||||
'SMTP_SECURE',
|
||||
'SMTP_USER',
|
||||
'SMTP_PASSWORD',
|
||||
'SMTP_FROM'
|
||||
'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], {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[dotenvxCli, action, '-f', stagingFile],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
stdio: 'inherit'
|
||||
});
|
||||
stdio: 'inherit',
|
||||
},
|
||||
);
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) throw new Error(`dotenvx ${action} failed.`);
|
||||
}
|
||||
@@ -35,7 +40,8 @@ try {
|
||||
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}.`);
|
||||
if (!pattern.test(contents))
|
||||
throw new Error(`Staging environment is missing ${key}.`);
|
||||
contents = contents.replace(pattern, replacement);
|
||||
}
|
||||
await writeFile(stagingFile, contents, 'utf8');
|
||||
@@ -43,4 +49,6 @@ try {
|
||||
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.',
|
||||
);
|
||||
|
||||
@@ -3,49 +3,78 @@ 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({
|
||||
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,
|
||||
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) => ({
|
||||
disableUrlAccess: true,
|
||||
})
|
||||
: null;
|
||||
const origin = (process.env.APP_ORIGIN || 'http://localhost:5173').replace(
|
||||
/\/+$/,
|
||||
'',
|
||||
);
|
||||
const escapeHtml = (value) =>
|
||||
String(value).replace(
|
||||
/[&<>"']/g,
|
||||
(character) =>
|
||||
({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
})[character]);
|
||||
"'": ''',
|
||||
})[character],
|
||||
);
|
||||
let delivered = 0;
|
||||
|
||||
for (const message of due) {
|
||||
const notificationId = randomUUID();
|
||||
await (await database.prepare(`INSERT INTO notifications
|
||||
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
|
||||
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);
|
||||
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);
|
||||
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({
|
||||
@@ -53,14 +82,23 @@ for (const message of due) {
|
||||
to: identity.email,
|
||||
subject: `SHD.build · ${message.title}`,
|
||||
text: `${message.body}\n\n${message.link ? `${origin}${message.link}` : origin}`,
|
||||
html: `<div style="background:#0a0c0d;color:#ecebe5;padding:28px;font-family:Arial,sans-serif"><p style="color:#f6a821;font-size:12px;letter-spacing:2px">${escapeHtml(message.source)}</p><h1>${escapeHtml(message.title)}</h1><p style="color:#a5aaab">${escapeHtml(message.body)}</p>${message.link ? `<a style="color:#f6a821" href="${escapeHtml(`${origin}${message.link}`)}">OPEN SHD.BUILD</a>` : ''}</div>`
|
||||
html: `<div style="background:#0a0c0d;color:#ecebe5;padding:28px;font-family:Arial,sans-serif"><p style="color:#f6a821;font-size:12px;letter-spacing:2px">${escapeHtml(message.source)}</p><h1>${escapeHtml(message.title)}</h1><p style="color:#a5aaab">${escapeHtml(message.body)}</p>${message.link ? `<a style="color:#f6a821" href="${escapeHtml(`${origin}${message.link}`)}">OPEN SHD.BUILD</a>` : ''}</div>`,
|
||||
});
|
||||
await (await database.prepare('UPDATE notifications SET emailed_at=? WHERE id=?')).run(now, notificationId);
|
||||
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);
|
||||
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'}.`,
|
||||
);
|
||||
|
||||
@@ -20,7 +20,7 @@ for (const file of iconFiles) {
|
||||
right: 2,
|
||||
bottom: 2,
|
||||
left: 2,
|
||||
background: { r: 0, g: 0, b: 0, alpha: 0 }
|
||||
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
||||
})
|
||||
.png({ compressionLevel: 9, adaptiveFiltering: true })
|
||||
.toFile(temporary);
|
||||
@@ -30,11 +30,15 @@ for (const file of iconFiles) {
|
||||
}
|
||||
|
||||
await rename(temporary, source);
|
||||
console.log(`${file}: ${before.width}x${before.height} -> ${result.width}x${result.height}`);
|
||||
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.`,
|
||||
);
|
||||
|
||||
+18122
-2020
File diff suppressed because one or more lines are too long
+130
-2
@@ -5,8 +5,136 @@
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>%sveltekit.status% · SHD.build system fault</title>
|
||||
<style>
|
||||
:root{color-scheme:dark;font-family:Arial,Helvetica,sans-serif;background:#0a0c0d;color:#ecebe5}*{box-sizing:border-box}body{min-height:100vh;margin:0;display:grid;place-items:center;padding:24px;background:linear-gradient(135deg,#101314 0,#0a0c0d 55%)}body:before{content:"";position:fixed;inset:0;pointer-events:none;opacity:.08;background-image:linear-gradient(#ffffff14 1px,transparent 1px),linear-gradient(90deg,#ffffff14 1px,transparent 1px);background-size:48px 48px}main{position:relative;width:min(760px,100%);border:1px solid #3b4142;border-top:2px solid #df8069;background:#111415}header{height:64px;display:flex;align-items:center;justify-content:space-between;padding:0 20px;border-bottom:1px solid #303536}header div{display:grid;gap:4px}header span{color:#858b8d;font-size:8px;font-weight:900;letter-spacing:1.5px}header strong{color:#df8069;font-size:11px;letter-spacing:1px}header b{font-size:32px;color:#df8069}section{padding:clamp(28px,6vw,58px)}p{color:#858b8d;font:15px/1.6 Georgia,serif}small{display:block;color:#f6a821;font-size:8px;font-weight:900;letter-spacing:1.8px}h1{max-width:620px;margin:14px 0;font-size:clamp(34px,7vw,62px);line-height:.92;letter-spacing:-2px}a{display:inline-block;margin-top:20px;padding:14px 18px;background:#f6a821;color:#111;font-size:9px;font-weight:900;letter-spacing:1.2px;text-decoration:none}footer{display:flex;align-items:center;gap:8px;padding:12px 20px;border-top:1px solid #303536;color:#697174;font-size:7px;letter-spacing:1px}footer i{width:6px;height:6px;background:#df8069;box-shadow:0 0 7px #df8069}
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
background: #0a0c0d;
|
||||
color: #ecebe5;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: linear-gradient(135deg, #101314 0, #0a0c0d 55%);
|
||||
}
|
||||
body:before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0.08;
|
||||
background-image:
|
||||
linear-gradient(#ffffff14 1px, transparent 1px),
|
||||
linear-gradient(90deg, #ffffff14 1px, transparent 1px);
|
||||
background-size: 48px 48px;
|
||||
}
|
||||
main {
|
||||
position: relative;
|
||||
width: min(760px, 100%);
|
||||
border: 1px solid #3b4142;
|
||||
border-top: 2px solid #df8069;
|
||||
background: #111415;
|
||||
}
|
||||
header {
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid #303536;
|
||||
}
|
||||
header div {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
header span {
|
||||
color: #858b8d;
|
||||
font-size: 8px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 1.5px;
|
||||
}
|
||||
header strong {
|
||||
color: #df8069;
|
||||
font-size: 11px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
header b {
|
||||
font-size: 32px;
|
||||
color: #df8069;
|
||||
}
|
||||
section {
|
||||
padding: clamp(28px, 6vw, 58px);
|
||||
}
|
||||
p {
|
||||
color: #858b8d;
|
||||
font:
|
||||
15px/1.6 Georgia,
|
||||
serif;
|
||||
}
|
||||
small {
|
||||
display: block;
|
||||
color: #f6a821;
|
||||
font-size: 8px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 1.8px;
|
||||
}
|
||||
h1 {
|
||||
max-width: 620px;
|
||||
margin: 14px 0;
|
||||
font-size: clamp(34px, 7vw, 62px);
|
||||
line-height: 0.92;
|
||||
letter-spacing: -2px;
|
||||
}
|
||||
a {
|
||||
display: inline-block;
|
||||
margin-top: 20px;
|
||||
padding: 14px 18px;
|
||||
background: #f6a821;
|
||||
color: #111;
|
||||
font-size: 9px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 1.2px;
|
||||
text-decoration: none;
|
||||
}
|
||||
footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid #303536;
|
||||
color: #697174;
|
||||
font-size: 7px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
footer i {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: #df8069;
|
||||
box-shadow: 0 0 7px #df8069;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body><main><header><div><span>ISAC FALLBACK CHANNEL</span><strong>CORE SHELL INTERRUPTED</strong></div><b>%sveltekit.status%</b></header><section><small>SHD NETWORK RESPONSE</small><h1>SYSTEM INTERFACE UNAVAILABLE</h1><p>%sveltekit.error.message%</p><a href="/">RETURN TO SHD.BUILD →</a></section><footer><i></i> FAULT ISOLATED · CORE SERVICES PROTECTED</footer></main></body>
|
||||
<body>
|
||||
<main>
|
||||
<header>
|
||||
<div>
|
||||
<span>ISAC FALLBACK CHANNEL</span
|
||||
><strong>CORE SHELL INTERRUPTED</strong>
|
||||
</div>
|
||||
<b>%sveltekit.status%</b>
|
||||
</header>
|
||||
<section>
|
||||
<small>SHD NETWORK RESPONSE</small>
|
||||
<h1>SYSTEM INTERFACE UNAVAILABLE</h1>
|
||||
<p>%sveltekit.error.message%</p>
|
||||
<a href="/">RETURN TO SHD.BUILD →</a>
|
||||
</section>
|
||||
<footer><i></i> FAULT ISOLATED · CORE SERVICES PROTECTED</footer>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+87
-25
@@ -2,53 +2,99 @@ 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/')) {
|
||||
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));
|
||||
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 (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 new Response(null, {
|
||||
status: 303,
|
||||
headers: { location: `${target.pathname}${target.search}` },
|
||||
});
|
||||
}
|
||||
return json(
|
||||
{
|
||||
error: accessBlock.kind === 'section'
|
||||
error:
|
||||
accessBlock.kind === 'section'
|
||||
? 'This section is not available.'
|
||||
: 'The public site is not available yet.'
|
||||
: 'The public site is not available yet.',
|
||||
},
|
||||
{ status: 404 }
|
||||
{ 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) : '';
|
||||
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);
|
||||
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 });
|
||||
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 });
|
||||
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);
|
||||
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);
|
||||
const result = proposalActionResult(
|
||||
module,
|
||||
action,
|
||||
Boolean(proposal.entityId),
|
||||
proposal.id,
|
||||
);
|
||||
if (module === 'attributes' && action === 'save') {
|
||||
Object.assign(result, {
|
||||
savedItem: {
|
||||
@@ -56,35 +102,51 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
name: String(submittedForm.get('name') ?? ''),
|
||||
type: String(submittedForm.get('type') ?? ''),
|
||||
category: String(submittedForm.get('category') ?? ''),
|
||||
calculation_type: String(submittedForm.get('calculationType') ?? ''),
|
||||
calculation_type: String(
|
||||
submittedForm.get('calculationType') ?? '',
|
||||
),
|
||||
unit: String(submittedForm.get('unit') ?? ''),
|
||||
active: 1,
|
||||
pending: true,
|
||||
proposalId: proposal.id,
|
||||
proposerName: user.displayName
|
||||
}
|
||||
proposerName: user.displayName,
|
||||
},
|
||||
});
|
||||
}
|
||||
return json({
|
||||
type: 'success',
|
||||
status: 200,
|
||||
data: stringify(result)
|
||||
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 new Response(null, {
|
||||
status: 303,
|
||||
headers: { location: `${target.pathname}${target.search}` },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return resolve(event);
|
||||
};
|
||||
|
||||
export const handleError: HandleServerError = ({ error, event, status, message }) => {
|
||||
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);
|
||||
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
|
||||
message:
|
||||
status >= 500
|
||||
? 'An unexpected fault interrupted this operation. No further action was applied.'
|
||||
: message,
|
||||
errorId,
|
||||
};
|
||||
};
|
||||
|
||||
+27
-12
@@ -3,7 +3,7 @@ import {
|
||||
agentProfileHref,
|
||||
displayNameKey,
|
||||
newAgentDisplayName,
|
||||
publicProfileSlugBase
|
||||
publicProfileSlugBase,
|
||||
} from './agents.js';
|
||||
|
||||
describe('display names', () => {
|
||||
@@ -19,29 +19,44 @@ describe('display names', () => {
|
||||
|
||||
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-装備');
|
||||
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');
|
||||
expect(
|
||||
publicProfileSlugBase('⚡️', '12345678-0000-4000-8000-000000000000'),
|
||||
).toBe('agent-12345678');
|
||||
});
|
||||
|
||||
it('keeps UUID-shaped display names out of the legacy UUID namespace', () => {
|
||||
expect(publicProfileSlugBase(
|
||||
expect(
|
||||
publicProfileSlugBase(
|
||||
'57a1160e-632f-4695-927f-7df03e20753f',
|
||||
'12345678-0000-4000-8000-000000000000'
|
||||
)).toBe('agent-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');
|
||||
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', () => {
|
||||
|
||||
+7
-3
@@ -3,7 +3,8 @@ export type AgentProfileReference = {
|
||||
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');
|
||||
@@ -13,7 +14,10 @@ export function newAgentDisplayName(sequence: number): string {
|
||||
return `Agent ${Math.max(1, Math.trunc(sequence))}`;
|
||||
}
|
||||
|
||||
export function publicProfileSlugBase(displayName: string, userId: string): string {
|
||||
export function publicProfileSlugBase(
|
||||
displayName: string,
|
||||
userId: string,
|
||||
): string {
|
||||
const normalized = displayName
|
||||
.normalize('NFKC')
|
||||
.toLocaleLowerCase('en-US')
|
||||
@@ -28,7 +32,7 @@ export function publicProfileSlugBase(displayName: string, userId: string): stri
|
||||
|
||||
export function agentProfileHref(
|
||||
agent: AgentProfileReference,
|
||||
fragment = ''
|
||||
fragment = '',
|
||||
): string {
|
||||
const identifier = agent.publicSlug || agent.id;
|
||||
const suffix = fragment ? `#${fragment.replace(/^#/, '')}` : '';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type {
|
||||
AiMechanicQuestion,
|
||||
AiMechanicResponseMeta
|
||||
AiMechanicResponseMeta,
|
||||
} from './mechanic-authoring';
|
||||
|
||||
export const AI_CALCULATION_STAGE_OPERATORS = [
|
||||
@@ -13,7 +13,7 @@ export const AI_CALCULATION_STAGE_OPERATORS = [
|
||||
'rate_of_fire',
|
||||
'magazine_size',
|
||||
'reload_speed',
|
||||
'firing_cycle'
|
||||
'firing_cycle',
|
||||
] as const;
|
||||
|
||||
export const AI_SKILL_CALCULATION_STAGES = [
|
||||
@@ -26,7 +26,7 @@ export const AI_SKILL_CALCULATION_STAGES = [
|
||||
'multishot_chance',
|
||||
'multishot_damage',
|
||||
'cooldown_recovery',
|
||||
'duration'
|
||||
'duration',
|
||||
] as const;
|
||||
|
||||
export type AiCalculationStageDraft = {
|
||||
|
||||
@@ -5,7 +5,7 @@ export const AI_MECHANIC_SOURCE_KINDS = [
|
||||
'exotic_weapon_talent',
|
||||
'os_protocol_talent',
|
||||
'skill_chip_talent',
|
||||
'specialization_tree_rank'
|
||||
'specialization_tree_rank',
|
||||
] as const;
|
||||
|
||||
export type AiMechanicSourceKind = (typeof AI_MECHANIC_SOURCE_KINDS)[number];
|
||||
@@ -14,33 +14,36 @@ export const AI_MECHANIC_SOURCE_PROFILES = {
|
||||
talent: {
|
||||
variantKeys: ['I', 'II'],
|
||||
allowAssignedAbility: false,
|
||||
allowProtocolUpgradeScaling: false
|
||||
allowProtocolUpgradeScaling: false,
|
||||
},
|
||||
exotic_weapon_talent: {
|
||||
variantKeys: ['I', 'II'],
|
||||
allowAssignedAbility: false,
|
||||
allowProtocolUpgradeScaling: false
|
||||
allowProtocolUpgradeScaling: false,
|
||||
},
|
||||
os_protocol_talent: {
|
||||
variantKeys: ['base'],
|
||||
allowAssignedAbility: false,
|
||||
allowProtocolUpgradeScaling: true
|
||||
allowProtocolUpgradeScaling: true,
|
||||
},
|
||||
skill_chip_talent: {
|
||||
variantKeys: ['minimum', 'maximum'],
|
||||
allowAssignedAbility: true,
|
||||
allowProtocolUpgradeScaling: false
|
||||
allowProtocolUpgradeScaling: false,
|
||||
},
|
||||
specialization_tree_rank: {
|
||||
variantKeys: ['base'],
|
||||
allowAssignedAbility: false,
|
||||
allowProtocolUpgradeScaling: false
|
||||
}
|
||||
} as const satisfies Record<AiMechanicSourceKind, {
|
||||
allowProtocolUpgradeScaling: false,
|
||||
},
|
||||
} as const satisfies Record<
|
||||
AiMechanicSourceKind,
|
||||
{
|
||||
variantKeys: readonly string[];
|
||||
allowAssignedAbility: boolean;
|
||||
allowProtocolUpgradeScaling: boolean;
|
||||
}>;
|
||||
}
|
||||
>;
|
||||
|
||||
export type AiMechanicContext = {
|
||||
sourceKind: AiMechanicSourceKind;
|
||||
|
||||
@@ -1,43 +1,51 @@
|
||||
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.'
|
||||
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' }
|
||||
{ key: 'tierII', variantKey: 'II', label: 'TIER II' },
|
||||
],
|
||||
allowAssignedAbility: false,
|
||||
allowProtocolUpgradeScaling: false,
|
||||
transcript: [{
|
||||
transcript: [
|
||||
{
|
||||
questionId: 'transfer-target',
|
||||
question: 'How is the next target selected?',
|
||||
answer: 'Nearest enemy'
|
||||
}],
|
||||
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: [{
|
||||
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
|
||||
}],
|
||||
workaround: null,
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
requestId: 'req_gap_123',
|
||||
model: 'test-model',
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
totalTokens: 150
|
||||
}
|
||||
}
|
||||
totalTokens: 150,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('mechanic developer handoff prompt', () => {
|
||||
@@ -56,7 +64,7 @@ describe('mechanic developer handoff prompt', () => {
|
||||
const prompt = buildMechanicDeveloperPrompt({
|
||||
...handoff,
|
||||
transcript: [],
|
||||
response: { ...handoff.response, assumptions: [], warnings: [] }
|
||||
response: { ...handoff.response, assumptions: [], warnings: [] },
|
||||
});
|
||||
|
||||
expect(prompt).not.toContain('## Clarification history');
|
||||
|
||||
@@ -2,7 +2,7 @@ import type {
|
||||
AiMechanicCapabilityGapResponse,
|
||||
AiMechanicContext,
|
||||
AiMechanicTranscriptEntry,
|
||||
AiMechanicValueColumn
|
||||
AiMechanicValueColumn,
|
||||
} from '$lib/ai/mechanic-authoring';
|
||||
|
||||
export type AiMechanicDeveloperHandoff = {
|
||||
@@ -18,21 +18,36 @@ function section(title: string, items: string[]) {
|
||||
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)}`
|
||||
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) => [
|
||||
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'));
|
||||
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.
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
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<string, unknown> =>
|
||||
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
const exactKeys = (value: Record<string, unknown>, 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<string>();
|
||||
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<string>();
|
||||
|
||||
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<string, unknown> | 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');
|
||||
}
|
||||
@@ -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'),
|
||||
],
|
||||
];
|
||||
},
|
||||
});
|
||||
+76
-16
@@ -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'] {
|
||||
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 {
|
||||
export function avatarUrl(
|
||||
source: unknown,
|
||||
value: unknown,
|
||||
userId: string,
|
||||
updatedAt?: unknown,
|
||||
): string {
|
||||
if (source === 'upload') {
|
||||
const version = updatedAt ? `?v=${encodeURIComponent(String(updatedAt))}` : '';
|
||||
const version = updatedAt
|
||||
? `?v=${encodeURIComponent(String(updatedAt))}`
|
||||
: '';
|
||||
return `/avatars/user/${encodeURIComponent(userId)}${version}`;
|
||||
}
|
||||
const preset = typeof value === 'string' && isAvatarPreset(value) ? value : DEFAULT_AVATAR_PRESET;
|
||||
const preset =
|
||||
typeof value === 'string' && isAvatarPreset(value)
|
||||
? value
|
||||
: DEFAULT_AVATAR_PRESET;
|
||||
return `/avatars/default/${preset}.webp`;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
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,
|
||||
@@ -7,9 +10,12 @@ import {
|
||||
equippedConditionalProgram,
|
||||
mechanicModifiersAt,
|
||||
skillModifierWindowsForMechanic,
|
||||
weaponCriticalChance
|
||||
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) {
|
||||
@@ -21,7 +27,7 @@ describe('loadout mechanic timeline', () => {
|
||||
|
||||
return buildWeaponMechanicTimeline(
|
||||
{ rpm: 60, magazine, reload_speed: 0 },
|
||||
profile
|
||||
profile,
|
||||
)
|
||||
.filter((event) => event.kind === 'shot_fired')
|
||||
.map((event) => event.values?.magazineTwentyPercentBandsCrossed);
|
||||
@@ -37,7 +43,7 @@ describe('loadout mechanic timeline', () => {
|
||||
const timeline = buildWeaponMechanicTimeline(
|
||||
{ rpm: 60, magazine: 100, reload_speed: 0 },
|
||||
profile,
|
||||
50
|
||||
50,
|
||||
);
|
||||
const criticalHits = timeline
|
||||
.filter((event) => event.kind === 'weapon_hit')
|
||||
@@ -55,7 +61,7 @@ describe('loadout mechanic timeline', () => {
|
||||
|
||||
const hitResults = buildWeaponMechanicTimeline(
|
||||
{ rpm: 60, magazine: 100, reload_speed: 0 },
|
||||
profile
|
||||
profile,
|
||||
)
|
||||
.filter((event) => event.kind === 'shot_fired')
|
||||
.map((event) => event.values?.hit);
|
||||
@@ -71,7 +77,7 @@ describe('loadout mechanic timeline', () => {
|
||||
const criticalKills = buildWeaponMechanicTimeline(
|
||||
{ rpm: 60, magazine: 100, reload_speed: 0 },
|
||||
profile,
|
||||
50
|
||||
50,
|
||||
)
|
||||
.filter((event) => event.kind === 'enemy_killed')
|
||||
.map((event) => event.values?.critical);
|
||||
@@ -87,7 +93,7 @@ describe('loadout mechanic timeline', () => {
|
||||
|
||||
const headshotKills = buildWeaponMechanicTimeline(
|
||||
{ rpm: 60, magazine: 100, reload_speed: 0 },
|
||||
profile
|
||||
profile,
|
||||
)
|
||||
.filter((event) => event.kind === 'enemy_killed')
|
||||
.map((event) => event.values?.headshot);
|
||||
@@ -103,7 +109,7 @@ describe('loadout mechanic timeline', () => {
|
||||
|
||||
const emptyStates = buildWeaponMechanicTimeline(
|
||||
{ rpm: 60, magazine: 2, reload_speed: 5 },
|
||||
profile
|
||||
profile,
|
||||
)
|
||||
.filter((event) => event.kind === 'reload_started')
|
||||
.map((event) => event.values?.emptyMagazine);
|
||||
@@ -117,27 +123,31 @@ describe('loadout mechanic timeline', () => {
|
||||
|
||||
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 }) => {
|
||||
{ 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('derives critical chance through the configured binding transform', () => {
|
||||
expect(weaponCriticalChance(
|
||||
{ critical: 80 },
|
||||
[{
|
||||
expect(
|
||||
weaponCriticalChance({ critical: 80 }, [
|
||||
{
|
||||
attributeId: 'critical',
|
||||
stage: 'critical_chance',
|
||||
operator: 'add',
|
||||
weight: 0.5,
|
||||
hardCap: 30,
|
||||
curveType: 'none'
|
||||
}]
|
||||
)).toBe(30);
|
||||
curveType: 'none',
|
||||
},
|
||||
]),
|
||||
).toBe(30);
|
||||
});
|
||||
|
||||
it('publishes one standardized movement state and lets sprinting win', () => {
|
||||
@@ -148,20 +158,22 @@ describe('loadout mechanic timeline', () => {
|
||||
{ rpm: 60, magazine: 100, reload_speed: 0 },
|
||||
profile,
|
||||
0,
|
||||
{ scenarioInputs: { moving: true, sprinting: false } }
|
||||
{ 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 } }
|
||||
{ scenarioInputs: { moving: true, sprinting: true } },
|
||||
).filter((event) => event.kind === 'movement_snapshot');
|
||||
|
||||
expect(moving.map((event) => event.values)).toEqual([
|
||||
{ state: 'moving' },
|
||||
{ state: 'moving' }
|
||||
{ state: 'moving' },
|
||||
]);
|
||||
expect(sprinting.every((event) => event.values?.state === 'sprinting')).toBe(true);
|
||||
expect(
|
||||
sprinting.every((event) => event.values?.state === 'sprinting'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('seeds Extra Health state before periodic stack evaluation', () => {
|
||||
@@ -170,45 +182,81 @@ describe('loadout mechanic timeline', () => {
|
||||
profile.extraHealthActive = true;
|
||||
const timeline = buildWeaponMechanicTimeline(
|
||||
{ rpm: 60, magazine: 100, reload_speed: 0 },
|
||||
profile
|
||||
profile,
|
||||
);
|
||||
expect(timeline.filter((event) => event.kind === 'extra_health_changed')).toEqual([{
|
||||
expect(
|
||||
timeline.filter((event) => event.kind === 'extra_health_changed'),
|
||||
).toEqual([
|
||||
{
|
||||
kind: 'extra_health_changed',
|
||||
at: 0,
|
||||
actorId: 'agent',
|
||||
values: { hasExtraHealth: true }
|
||||
}]);
|
||||
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 }
|
||||
{
|
||||
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: [{
|
||||
clauses: [
|
||||
{
|
||||
id: 'set-active',
|
||||
when: 'extra_health_changed',
|
||||
if: { kind: 'compare', left: { source: 'event', path: 'values.hasExtraHealth' }, operator: 'eq', right: true },
|
||||
if: {
|
||||
kind: 'compare',
|
||||
left: { source: 'event', path: 'values.hasExtraHealth' },
|
||||
operator: 'eq',
|
||||
right: true,
|
||||
},
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [{ kind: 'set', pool: 'active', value: 1 }]
|
||||
}, {
|
||||
mutations: [{ kind: 'set', pool: 'active', value: 1 }],
|
||||
},
|
||||
{
|
||||
id: 'gain-stack',
|
||||
when: 'tick',
|
||||
if: { kind: 'compare', left: { source: 'pool', pool: 'active' }, operator: 'eq', right: 1 },
|
||||
if: {
|
||||
kind: 'compare',
|
||||
left: { source: 'pool', pool: 'active' },
|
||||
operator: 'eq',
|
||||
right: 1,
|
||||
},
|
||||
reducer: { kind: 'periodic', intervalSeconds: 1 },
|
||||
mutations: [{ kind: 'add', pool: 'stacks', value: 1 }]
|
||||
}]
|
||||
mutations: [{ kind: 'add', pool: 'stacks', value: 1 }],
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = evaluateMechanicProgram(program, timeline, {
|
||||
mode: 'custom',
|
||||
encounterSeconds: 2
|
||||
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]);
|
||||
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', () => {
|
||||
@@ -220,10 +268,11 @@ describe('loadout mechanic timeline', () => {
|
||||
configured: 1,
|
||||
script_version: 1,
|
||||
simulation_readiness: 'ready',
|
||||
scenario_inputs_json: '[]'
|
||||
scenario_inputs_json: '[]',
|
||||
},
|
||||
statePools: [],
|
||||
clauses: [{
|
||||
clauses: [
|
||||
{
|
||||
id: 'shot-clause',
|
||||
rule_id: 'tier-i-only',
|
||||
clause_key: 'shot',
|
||||
@@ -239,9 +288,11 @@ describe('loadout mechanic timeline', () => {
|
||||
cooldown_seconds: null,
|
||||
output_branch: 'active',
|
||||
sort_order: 1,
|
||||
active: 1
|
||||
}],
|
||||
outcomes: [{
|
||||
active: 1,
|
||||
},
|
||||
],
|
||||
outcomes: [
|
||||
{
|
||||
id: 'weapon-damage-i',
|
||||
rule_id: 'tier-i-only',
|
||||
clause_id: 'shot-clause',
|
||||
@@ -267,20 +318,21 @@ describe('loadout mechanic timeline', () => {
|
||||
min_state_value: null,
|
||||
max_state_value: null,
|
||||
description_slot: 1,
|
||||
sort_order: 1
|
||||
}],
|
||||
triggers: []
|
||||
sort_order: 1,
|
||||
},
|
||||
],
|
||||
triggers: [],
|
||||
};
|
||||
|
||||
const adapted = equippedConditionalProgram({
|
||||
key: 'weapon:0:exotic-talent',
|
||||
tier: 2,
|
||||
rule: persistedRule
|
||||
rule: persistedRule,
|
||||
});
|
||||
|
||||
expect(adapted?.clauses[0].outcomes?.[0]).toMatchObject({
|
||||
id: 'weapon-damage-i',
|
||||
value: 15
|
||||
value: 15,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -296,7 +348,7 @@ describe('movement-charge exotic mechanic wiring', () => {
|
||||
targetCap: 1,
|
||||
targetCount: 1,
|
||||
chargePolicy: { type: 'none' },
|
||||
activation: { mode: 'on_cooldown' }
|
||||
activation: { mode: 'on_cooldown' },
|
||||
};
|
||||
const program: MechanicProgram = {
|
||||
id: 'movement-charge-exotic',
|
||||
@@ -307,15 +359,15 @@ describe('movement-charge exotic mechanic wiring', () => {
|
||||
label: 'Moving',
|
||||
kind: 'toggle',
|
||||
defaultValue: false,
|
||||
bestCaseValue: true
|
||||
bestCaseValue: true,
|
||||
},
|
||||
{
|
||||
key: 'sprinting',
|
||||
label: 'Sprinting',
|
||||
kind: 'toggle',
|
||||
defaultValue: false,
|
||||
bestCaseValue: true
|
||||
}
|
||||
bestCaseValue: true,
|
||||
},
|
||||
],
|
||||
pools: [
|
||||
{
|
||||
@@ -325,7 +377,7 @@ describe('movement-charge exotic mechanic wiring', () => {
|
||||
initial: 0,
|
||||
min: 0,
|
||||
max: 40,
|
||||
bestCaseValue: 40
|
||||
bestCaseValue: 40,
|
||||
},
|
||||
{
|
||||
key: 'kill-speed',
|
||||
@@ -334,8 +386,8 @@ describe('movement-charge exotic mechanic wiring', () => {
|
||||
initial: 0,
|
||||
min: 0,
|
||||
max: 1,
|
||||
expiresAfterSeconds: 10
|
||||
}
|
||||
expiresAfterSeconds: 10,
|
||||
},
|
||||
],
|
||||
clauses: [
|
||||
{
|
||||
@@ -345,10 +397,10 @@ describe('movement-charge exotic mechanic wiring', () => {
|
||||
kind: 'compare',
|
||||
left: { source: 'event', path: 'values.state' },
|
||||
operator: 'eq',
|
||||
right: 'moving'
|
||||
right: 'moving',
|
||||
},
|
||||
reducer: { kind: 'periodic', intervalSeconds: 1 },
|
||||
mutations: [{ kind: 'add', pool: 'movement-charge', value: 4 }]
|
||||
mutations: [{ kind: 'add', pool: 'movement-charge', value: 4 }],
|
||||
},
|
||||
{
|
||||
id: 'gain-while-sprinting',
|
||||
@@ -357,10 +409,10 @@ describe('movement-charge exotic mechanic wiring', () => {
|
||||
kind: 'compare',
|
||||
left: { source: 'event', path: 'values.state' },
|
||||
operator: 'eq',
|
||||
right: 'sprinting'
|
||||
right: 'sprinting',
|
||||
},
|
||||
reducer: { kind: 'periodic', intervalSeconds: 1 },
|
||||
mutations: [{ kind: 'add', pool: 'movement-charge', value: 8 }]
|
||||
mutations: [{ kind: 'add', pool: 'movement-charge', value: 8 }],
|
||||
},
|
||||
{
|
||||
id: 'spend-on-shot',
|
||||
@@ -369,11 +421,12 @@ describe('movement-charge exotic mechanic wiring', () => {
|
||||
kind: 'compare',
|
||||
left: { source: 'pool', pool: 'movement-charge' },
|
||||
operator: 'gt',
|
||||
right: 0
|
||||
right: 0,
|
||||
},
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [{ kind: 'consume', pool: 'movement-charge', value: 1 }],
|
||||
outcomes: [{
|
||||
outcomes: [
|
||||
{
|
||||
id: 'shot-weapon-damage',
|
||||
label: 'Shot Weapon Damage',
|
||||
phase: 'on_event',
|
||||
@@ -381,8 +434,9 @@ describe('movement-charge exotic mechanic wiring', () => {
|
||||
scope: 'equipped_weapon',
|
||||
value: 15,
|
||||
basis: 'flat',
|
||||
attributeId: 'weapon-damage'
|
||||
}]
|
||||
attributeId: 'weapon-damage',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'spend-on-skill',
|
||||
@@ -391,11 +445,12 @@ describe('movement-charge exotic mechanic wiring', () => {
|
||||
kind: 'compare',
|
||||
left: { source: 'pool', pool: 'movement-charge' },
|
||||
operator: 'gte',
|
||||
right: 10
|
||||
right: 10,
|
||||
},
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [{ kind: 'consume', pool: 'movement-charge', value: 10 }],
|
||||
outcomes: [{
|
||||
outcomes: [
|
||||
{
|
||||
id: 'deployment-skill-damage',
|
||||
label: 'Deployment Skill Damage',
|
||||
phase: 'on_event',
|
||||
@@ -403,15 +458,17 @@ describe('movement-charge exotic mechanic wiring', () => {
|
||||
scope: 'all_skills',
|
||||
value: 15,
|
||||
basis: 'flat',
|
||||
attributeId: 'skill-damage'
|
||||
}]
|
||||
attributeId: 'skill-damage',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'speed-on-kill',
|
||||
when: 'enemy_killed',
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [{ kind: 'set', pool: 'kill-speed', value: 1 }],
|
||||
outcomes: [{
|
||||
outcomes: [
|
||||
{
|
||||
id: 'kill-movement-speed',
|
||||
label: 'Kill Movement Speed',
|
||||
phase: 'while_active',
|
||||
@@ -420,10 +477,11 @@ describe('movement-charge exotic mechanic wiring', () => {
|
||||
value: 10,
|
||||
basis: 'flat',
|
||||
attributeId: 'movement-speed',
|
||||
statePool: 'kill-speed'
|
||||
}]
|
||||
}
|
||||
]
|
||||
statePool: 'kill-speed',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it('gains stacks, consumes them on shots and eligible deployments, and applies timed outcomes', () => {
|
||||
@@ -437,114 +495,136 @@ describe('movement-charge exotic mechanic wiring', () => {
|
||||
0,
|
||||
{
|
||||
scenarioInputs: { moving: true, sprinting: false },
|
||||
skillDeploymentEvents: skillDeployments
|
||||
}
|
||||
).filter((event) =>
|
||||
event.kind === 'movement_snapshot' || event.kind === 'skill_deployed'),
|
||||
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 }
|
||||
{ kind: 'enemy_killed' as const, at: 4 },
|
||||
];
|
||||
const result = evaluateMechanicProgram(
|
||||
program,
|
||||
events,
|
||||
{
|
||||
const result = evaluateMechanicProgram(program, events, {
|
||||
mode: 'custom',
|
||||
encounterSeconds: 15,
|
||||
inputs: { moving: true, sprinting: false }
|
||||
}
|
||||
);
|
||||
inputs: { moving: true, sprinting: false },
|
||||
});
|
||||
|
||||
expect(skillDeployments).toEqual([
|
||||
{
|
||||
kind: 'skill_deployed',
|
||||
at: 0,
|
||||
targetId: 'skill-a',
|
||||
values: { abilityId: 'skill-a' }
|
||||
values: { abilityId: 'skill-a' },
|
||||
},
|
||||
{
|
||||
kind: 'skill_deployed',
|
||||
at: 10,
|
||||
targetId: 'skill-a',
|
||||
values: { abilityId: '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(
|
||||
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
|
||||
appliedValue: 15,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
outcomeId: 'deployment-skill-damage',
|
||||
at: 10,
|
||||
targetId: 'skill-a',
|
||||
appliedValue: 15
|
||||
appliedValue: 15,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
outcomeId: 'kill-movement-speed',
|
||||
at: 4,
|
||||
appliedValue: 10
|
||||
})
|
||||
]));
|
||||
expect(result.finalState).toContainEqual(expect.objectContaining({
|
||||
appliedValue: 10,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(result.finalState).toContainEqual(
|
||||
expect.objectContaining({
|
||||
pool: 'kill-speed',
|
||||
value: 0,
|
||||
expiresAt: null
|
||||
}));
|
||||
expiresAt: null,
|
||||
}),
|
||||
);
|
||||
|
||||
const source = {
|
||||
status: 'OK' as const,
|
||||
result,
|
||||
program,
|
||||
encounterSeconds: 15,
|
||||
events
|
||||
events,
|
||||
};
|
||||
const skillWindows = skillModifierWindowsForMechanic(
|
||||
source,
|
||||
null,
|
||||
new Set(['skill-a']),
|
||||
new Set(['skill-damage'])
|
||||
new Set(['skill-damage']),
|
||||
);
|
||||
const movementWindows = skillModifierWindowsForMechanic(
|
||||
source,
|
||||
null,
|
||||
new Set(['skill-a']),
|
||||
new Set(['movement-speed'])
|
||||
new Set(['movement-speed']),
|
||||
);
|
||||
expect(skillWindows).toEqual([{
|
||||
expect(skillWindows).toEqual([
|
||||
{
|
||||
startSeconds: 10,
|
||||
endSeconds: 15,
|
||||
attributes: { 'skill-damage': 15 },
|
||||
abilityIds: ['skill-a']
|
||||
}]);
|
||||
expect(movementWindows).toEqual([{
|
||||
abilityIds: ['skill-a'],
|
||||
},
|
||||
]);
|
||||
expect(movementWindows).toEqual([
|
||||
{
|
||||
startSeconds: 4,
|
||||
endSeconds: 14,
|
||||
attributes: { 'movement-speed': 10 },
|
||||
abilityIds: undefined
|
||||
}]);
|
||||
abilityIds: undefined,
|
||||
},
|
||||
]);
|
||||
|
||||
const skillResult = calculateSkillDps({
|
||||
rules: {
|
||||
revisionId: 'test',
|
||||
bindings: [{ attributeId: 'skill-damage', stage: 'skill_damage', operator: 'add' }]
|
||||
bindings: [
|
||||
{
|
||||
attributeId: 'skill-damage',
|
||||
stage: 'skill_damage',
|
||||
operator: 'add',
|
||||
},
|
||||
],
|
||||
},
|
||||
abilities: [ability],
|
||||
attributes: {},
|
||||
modifierWindows: skillWindows,
|
||||
assumptions: { encounterSeconds: 15, targetCount: 1 }
|
||||
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);
|
||||
expect(skillResult.abilities[0].damage.singleTargetEncounterDamage).toBe(
|
||||
215,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -553,15 +633,17 @@ describe('per-shot mechanic modifiers', () => {
|
||||
const program: MechanicProgram = {
|
||||
id: 'phase-order',
|
||||
name: 'Phase order',
|
||||
pools: [{
|
||||
pools: [
|
||||
{
|
||||
key: 'stacks',
|
||||
label: 'Stacks',
|
||||
scope: 'agent',
|
||||
initial: 0,
|
||||
min: 0,
|
||||
max: 10,
|
||||
expiresAfterSeconds: 10
|
||||
}],
|
||||
expiresAfterSeconds: 10,
|
||||
},
|
||||
],
|
||||
clauses: [
|
||||
{
|
||||
id: 'stack-on-hit',
|
||||
@@ -577,7 +659,7 @@ describe('per-shot mechanic modifiers', () => {
|
||||
scope: 'equipped_weapon',
|
||||
value: 2,
|
||||
basis: 'flat',
|
||||
attributeId: 'weapon-damage'
|
||||
attributeId: 'weapon-damage',
|
||||
},
|
||||
{
|
||||
id: 'after-hit',
|
||||
@@ -587,7 +669,7 @@ describe('per-shot mechanic modifiers', () => {
|
||||
scope: 'equipped_weapon',
|
||||
value: 100,
|
||||
basis: 'flat',
|
||||
attributeId: 'weapon-damage'
|
||||
attributeId: 'weapon-damage',
|
||||
},
|
||||
{
|
||||
id: 'active-stacks',
|
||||
@@ -599,16 +681,17 @@ describe('per-shot mechanic modifiers', () => {
|
||||
basis: 'flat',
|
||||
attributeId: 'weapon-damage',
|
||||
statePool: 'stacks',
|
||||
perStack: true
|
||||
}
|
||||
]
|
||||
perStack: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'second-instant',
|
||||
when: 'weapon_hit',
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [],
|
||||
outcomes: [{
|
||||
outcomes: [
|
||||
{
|
||||
id: 'instant-b',
|
||||
label: 'Instant B',
|
||||
phase: 'on_event',
|
||||
@@ -616,32 +699,35 @@ describe('per-shot mechanic modifiers', () => {
|
||||
scope: 'equipped_weapon',
|
||||
value: 5,
|
||||
basis: 'flat',
|
||||
attributeId: 'weapon-damage'
|
||||
}]
|
||||
}
|
||||
]
|
||||
attributeId: 'weapon-damage',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const evaluation = evaluateMechanicProgram(
|
||||
program,
|
||||
[
|
||||
{ kind: 'weapon_hit', at: 1, targetId: 'target' },
|
||||
{ kind: 'weapon_hit', at: 2, targetId: 'target' }
|
||||
{ kind: 'weapon_hit', at: 2, targetId: 'target' },
|
||||
],
|
||||
{ mode: 'custom', encounterSeconds: 5 }
|
||||
{ mode: 'custom', encounterSeconds: 5 },
|
||||
);
|
||||
const source = {
|
||||
status: 'OK' as const,
|
||||
result: evaluation,
|
||||
program,
|
||||
encounterSeconds: 5
|
||||
encounterSeconds: 5,
|
||||
};
|
||||
|
||||
it('combines same-attribute on-event effects and excludes after-event effects', () => {
|
||||
expect(mechanicModifiersAt(source, 1, 'weapon_hit')).toEqual([{
|
||||
expect(mechanicModifiersAt(source, 1, 'weapon_hit')).toEqual([
|
||||
{
|
||||
attributeId: 'weapon-damage',
|
||||
scope: 'equipped_weapon',
|
||||
value: 7
|
||||
}]);
|
||||
value: 7,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the pre-hit active stack state when the triggering hit adds another stack', () => {
|
||||
|
||||
+790
-338
File diff suppressed because it is too large
Load Diff
@@ -46,8 +46,8 @@ describe('parseLoadoutDraft', () => {
|
||||
mode: 'encounter',
|
||||
inputs: {
|
||||
alliesWithinRange: 3,
|
||||
targetHasDebuff: true
|
||||
}
|
||||
targetHasDebuff: true,
|
||||
},
|
||||
};
|
||||
|
||||
expect(parseLoadoutDraft(draft)).toEqual(draft);
|
||||
@@ -64,7 +64,9 @@ describe('parseLoadoutDraft', () => {
|
||||
});
|
||||
|
||||
it('rejects a specialization that carries retired unassigned chips', () => {
|
||||
const draft = copyDraft() as unknown as { specialization: Record<string, unknown> };
|
||||
const draft = copyDraft() as unknown as {
|
||||
specialization: Record<string, unknown>;
|
||||
};
|
||||
draft.specialization.unassignedChips = [];
|
||||
|
||||
expect(parseLoadoutDraft(draft)).toBeNull();
|
||||
|
||||
+352
-117
@@ -1,8 +1,40 @@
|
||||
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;
|
||||
@@ -41,7 +73,17 @@ export type LoadoutDraft = {
|
||||
calculationMode: CalculationMode;
|
||||
gear: Record<string, GearSelection>;
|
||||
weapons: [WeaponSelection, WeaponSelection];
|
||||
chips: [ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection];
|
||||
chips: [
|
||||
ChipSelection,
|
||||
ChipSelection,
|
||||
ChipSelection,
|
||||
ChipSelection,
|
||||
ChipSelection,
|
||||
ChipSelection,
|
||||
ChipSelection,
|
||||
ChipSelection,
|
||||
ChipSelection,
|
||||
];
|
||||
protocol: ProtocolSelection;
|
||||
conditionalEffects: Record<string, ConditionalEffectSelection>;
|
||||
combatProfile: CombatProfile;
|
||||
@@ -49,11 +91,46 @@ export type LoadoutDraft = {
|
||||
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,
|
||||
@@ -68,7 +145,7 @@ export const defaultCombatProfile = (): CombatProfile => ({
|
||||
swapsPerMinute: 2,
|
||||
targetDistanceMeters: 15,
|
||||
healthPercent: 100,
|
||||
extraHealthActive: false
|
||||
extraHealthActive: false,
|
||||
});
|
||||
export const createDraft = (): LoadoutDraft => ({
|
||||
version: 4,
|
||||
@@ -76,135 +153,267 @@ export const createDraft = (): LoadoutDraft => ({
|
||||
calculationMode: 'maximum',
|
||||
gear: Object.fromEntries(gearSlots.map((slot) => [slot, blankGear()])),
|
||||
weapons: [blankWeapon(), blankWeapon()],
|
||||
chips: [blankChip(), blankChip(), blankChip(), blankChip(), blankChip(), blankChip(), blankChip(), blankChip(), blankChip()],
|
||||
chips: [
|
||||
blankChip(),
|
||||
blankChip(),
|
||||
blankChip(),
|
||||
blankChip(),
|
||||
blankChip(),
|
||||
blankChip(),
|
||||
blankChip(),
|
||||
blankChip(),
|
||||
blankChip(),
|
||||
],
|
||||
protocol: { itemId: '', level: 1, talentUpgrades: 0 },
|
||||
conditionalEffects: {},
|
||||
combatProfile: defaultCombatProfile(),
|
||||
skillActivations: {},
|
||||
specialization: {
|
||||
focusId: '',
|
||||
nodeRanks: {}
|
||||
}
|
||||
nodeRanks: {},
|
||||
},
|
||||
});
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
const hasExactKeys = (value: Record<string, unknown>, 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);
|
||||
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<string, ConditionalEffectSelection> =>
|
||||
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)
|
||||
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<string, ConditionalEffectSelection> =>
|
||||
isRecord(value) &&
|
||||
Object.values(value).every((entry) => {
|
||||
if (
|
||||
!isRecord(entry) ||
|
||||
!['off', 'peak', 'encounter'].includes(String(entry.mode))
|
||||
)
|
||||
)) return false;
|
||||
return entry.overrides === undefined || (
|
||||
isRecord(entry.overrides)
|
||||
&& Object.entries(entry.overrides).every(([key, override]) =>
|
||||
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)
|
||||
)
|
||||
: key in defaultCombatProfile() && isFiniteNumber(override),
|
||||
))
|
||||
);
|
||||
});
|
||||
const isSkillActivations = (value: unknown): value is Record<string, SkillActivationSelection> =>
|
||||
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))
|
||||
const isSkillActivations = (
|
||||
value: unknown,
|
||||
): value is Record<string, SkillActivationSelection> =>
|
||||
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;
|
||||
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;
|
||||
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);
|
||||
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));
|
||||
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;
|
||||
@@ -215,36 +424,54 @@ export type LevelScaledRange = {
|
||||
};
|
||||
|
||||
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 {
|
||||
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);
|
||||
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
|
||||
provisional: target < 10,
|
||||
};
|
||||
}
|
||||
|
||||
export function weaponBaseRange(minimum: unknown, maximum: unknown, targetLevel: unknown): LevelScaledRange {
|
||||
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);
|
||||
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
|
||||
provisional: target < 40,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -253,7 +480,7 @@ export function levelScaledRange(
|
||||
maximum: unknown,
|
||||
targetLevel: unknown,
|
||||
referenceLevel: unknown,
|
||||
source: 'gear' | 'weapon'
|
||||
source: 'gear' | 'weapon',
|
||||
): LevelScaledRange {
|
||||
const target = itemLevel(targetLevel);
|
||||
const reference = itemLevel(referenceLevel);
|
||||
@@ -268,12 +495,20 @@ export function levelScaledRange(
|
||||
rawMax,
|
||||
min: Math.floor(rawMin),
|
||||
max: Math.floor(rawMax),
|
||||
provisional: source === 'gear' ? target < 10 || reference < 10 : target < 40 || reference < 40
|
||||
provisional:
|
||||
source === 'gear'
|
||||
? target < 10 || reference < 10
|
||||
: target < 40 || reference < 40,
|
||||
};
|
||||
}
|
||||
|
||||
export function remapRangeValue(value: unknown, from: LevelScaledRange, to: LevelScaledRange) {
|
||||
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;
|
||||
const position =
|
||||
span > 0 ? (clamp(value, from.min, from.max) - from.min) / span : 0;
|
||||
return Math.round(to.min + (to.max - to.min) * position);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
formatItemDamageRangeScript,
|
||||
formatWeaponCalculationScript
|
||||
formatWeaponCalculationScript,
|
||||
} from './calculation-script';
|
||||
|
||||
describe('formatWeaponCalculationScript', () => {
|
||||
@@ -9,11 +9,27 @@ describe('formatWeaponCalculationScript', () => {
|
||||
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 }
|
||||
{
|
||||
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: [{
|
||||
profiles: [
|
||||
{
|
||||
stat_attribute_id: 'weapon-flat',
|
||||
stage_key: 'flat',
|
||||
weight: 1.5,
|
||||
@@ -21,19 +37,30 @@ describe('formatWeaponCalculationScript', () => {
|
||||
hard_cap: 100,
|
||||
curve_type: 'none',
|
||||
curve_parameter: null,
|
||||
weapon_category: 'SMG'
|
||||
}],
|
||||
attributes: [{ id: 'weapon-flat', name: 'Flat Weapon Damage', unit: 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).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');
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,9 +28,12 @@ export type CalculationScriptRevision = {
|
||||
};
|
||||
|
||||
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 ?? '')
|
||||
const identifier = (value: unknown) =>
|
||||
String(value ?? '')
|
||||
.normalize('NFKC')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
@@ -39,7 +42,7 @@ const identifier = (value: unknown) => String(value ?? '')
|
||||
|
||||
function profileExpression(
|
||||
profile: CalculationScriptProfile,
|
||||
attributes: Map<string, CalculationScriptAttribute>
|
||||
attributes: Map<string, CalculationScriptAttribute>,
|
||||
) {
|
||||
const attribute = attributes.get(profile.stat_attribute_id);
|
||||
const unit = attribute?.unit ? ` ${attribute.unit}` : '';
|
||||
@@ -47,7 +50,8 @@ function profileExpression(
|
||||
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'
|
||||
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}`;
|
||||
@@ -61,7 +65,11 @@ function profileExpression(
|
||||
return expression;
|
||||
}
|
||||
|
||||
const operatorStatement = (operator: string, label: string, sources: string[]) => {
|
||||
const operatorStatement = (
|
||||
operator: string,
|
||||
label: string,
|
||||
sources: string[],
|
||||
) => {
|
||||
const source = sources.length ? sources.join(', ') : 'no_bound_attributes';
|
||||
switch (operator) {
|
||||
case 'base_damage':
|
||||
@@ -75,12 +83,12 @@ const operatorStatement = (operator: string, label: string, sources: string[]) =
|
||||
case 'expected_critical':
|
||||
return [
|
||||
`WEIGHT critical_damage BY critical_chance`,
|
||||
...(sources.length ? [`SOURCE ${source}`] : [])
|
||||
...(sources.length ? [`SOURCE ${source}`] : []),
|
||||
];
|
||||
case 'expected_headshot':
|
||||
return [
|
||||
`WEIGHT headshot_damage BY headshot_chance`,
|
||||
...(sources.length ? [`SOURCE ${source}`] : [])
|
||||
...(sources.length ? [`SOURCE ${source}`] : []),
|
||||
];
|
||||
case 'rate_of_fire':
|
||||
return [`ADJUST equipped_weapon.rpm USING ${source}`];
|
||||
@@ -91,7 +99,7 @@ const operatorStatement = (operator: string, label: string, sources: string[]) =
|
||||
case 'firing_cycle':
|
||||
return [
|
||||
'CALCULATE firing_cycle FROM rpm, magazine, reload_seconds',
|
||||
'RETURN expected_damage_per_second'
|
||||
'RETURN expected_damage_per_second',
|
||||
];
|
||||
default:
|
||||
return [`APPLY ${identifier(label)} USING ${source}`];
|
||||
@@ -104,18 +112,26 @@ export function formatWeaponCalculationScript(input: {
|
||||
profiles: CalculationScriptProfile[];
|
||||
attributes: CalculationScriptAttribute[];
|
||||
}) {
|
||||
const attributes = new Map(input.attributes.map((attribute) => [String(attribute.id), attribute]));
|
||||
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}` : ''}`
|
||||
`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)
|
||||
(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));
|
||||
lines.push(
|
||||
...operatorStatement(
|
||||
String(stage.operator),
|
||||
String(stage.label),
|
||||
sources,
|
||||
),
|
||||
);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
@@ -126,6 +142,6 @@ export function formatItemDamageRangeScript() {
|
||||
'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'
|
||||
'PASS equipped_weapon.damage TO combat_calculation',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -2,28 +2,57 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
calculateCombat,
|
||||
expectedWeaponShotDamage,
|
||||
type CombatCalculationInput
|
||||
type CombatCalculationInput,
|
||||
} from './combat.js';
|
||||
|
||||
const base = (overrides: Partial<CombatCalculationInput> = {}): CombatCalculationInput => ({
|
||||
weapon: { baseDamage: 100, rpm: 600, magazine: 10, reloadSeconds: 2, headshotMultiplier: 2 },
|
||||
attributes: { weapon: 20, critChance: 25, critDamage: 50, headshotChance: 0, final: 10 },
|
||||
const base = (
|
||||
overrides: Partial<CombatCalculationInput> = {},
|
||||
): 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' }
|
||||
{
|
||||
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
|
||||
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.hitValues).toEqual({
|
||||
normal: 132,
|
||||
critical: 198,
|
||||
headshot: 264,
|
||||
criticalHeadshot: 396,
|
||||
});
|
||||
expect(result.burstDps).toBe(1485);
|
||||
expect(result.sustainedDps).toBeCloseTo(495);
|
||||
});
|
||||
@@ -32,18 +61,37 @@ describe('calculateCombat', () => {
|
||||
const input = base({
|
||||
attributes: { critChance: 50, critDamage: 100 },
|
||||
bindings: [
|
||||
{ attributeId: 'critChance', stage: 'critical_chance', operator: 'add' },
|
||||
{ attributeId: 'critDamage', stage: 'critical_damage', operator: 'add' }
|
||||
]
|
||||
{
|
||||
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);
|
||||
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({
|
||||
const result = calculateCombat(
|
||||
base({
|
||||
attributes: { weapon: 100 },
|
||||
bindings: [{
|
||||
bindings: [
|
||||
{
|
||||
attributeId: 'weapon',
|
||||
stage: 'weapon_damage',
|
||||
operator: 'add',
|
||||
@@ -51,53 +99,77 @@ describe('calculateCombat', () => {
|
||||
softCap: 50,
|
||||
hardCap: 80,
|
||||
curveType: 'power',
|
||||
curveParameter: 1
|
||||
}]
|
||||
}));
|
||||
curveParameter: 1,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(result.hitValues?.normal).toBe(180);
|
||||
});
|
||||
|
||||
it('applies firing-cycle attributes to burst and sustained DPS', () => {
|
||||
const result = calculateCombat(base({
|
||||
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' }
|
||||
]
|
||||
}));
|
||||
{ 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 = [{
|
||||
const stackingEffects = [
|
||||
{
|
||||
id: 'relentless',
|
||||
trigger: { hitsRequired: 9, windowSeconds: 3 },
|
||||
lifecycle: { maxStacks: 10, durationSeconds: 10, stacksPerTrigger: 1 },
|
||||
attributes: { weapon: 3, critDamage: 1 }
|
||||
}];
|
||||
attributes: { weapon: 3, critDamage: 1 },
|
||||
},
|
||||
];
|
||||
const off = calculateCombat(base({ stackingEffects }));
|
||||
const peak = calculateCombat(base({
|
||||
const peak = calculateCombat(
|
||||
base({
|
||||
stackingEffects,
|
||||
assumptions: { mode: 'off', perEffect: { relentless: { mode: 'peak' } } }
|
||||
}));
|
||||
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: [{
|
||||
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 }
|
||||
}));
|
||||
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);
|
||||
@@ -105,37 +177,60 @@ describe('calculateCombat', () => {
|
||||
});
|
||||
|
||||
it('does not trigger when expected hits cannot meet the trigger window', () => {
|
||||
const result = calculateCombat(base({
|
||||
stackingEffects: [{
|
||||
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 });
|
||||
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({
|
||||
const result = calculateCombat(
|
||||
base({
|
||||
weapon: { baseDamage: 100, rpm: 0, magazine: 0, reloadSeconds: -1 },
|
||||
assumptions: { mode: 'encounter' },
|
||||
stackingEffects: [{
|
||||
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([
|
||||
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'
|
||||
]));
|
||||
'assumptions.encounterSeconds',
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+219
-84
@@ -36,7 +36,11 @@ export type WeaponFiringCycle = {
|
||||
};
|
||||
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;
|
||||
@@ -113,19 +117,32 @@ type TimedStackEffect = {
|
||||
};
|
||||
|
||||
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 {
|
||||
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)
|
||||
firingActivity: percent(
|
||||
override.firingActivity ?? base.firingActivity,
|
||||
100,
|
||||
),
|
||||
encounterSeconds: Number(
|
||||
override.encounterSeconds ?? base.encounterSeconds ?? 0,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -133,13 +150,25 @@ 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 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);
|
||||
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);
|
||||
@@ -150,47 +179,83 @@ function transformedValue(raw: number, binding: AttributeBinding) {
|
||||
return value * sign;
|
||||
}
|
||||
|
||||
function addAttributes(target: StatValues, incoming: StatValues, multiplier = 1) {
|
||||
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;
|
||||
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.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));
|
||||
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 (!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');
|
||||
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 (!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 (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) {
|
||||
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);
|
||||
const value = transformedValue(
|
||||
Number(attributes[binding.attributeId] ?? 0),
|
||||
binding,
|
||||
);
|
||||
if (binding.operator === 'add') additive += value;
|
||||
else multiplicative *= value;
|
||||
}
|
||||
@@ -201,16 +266,22 @@ function damageBase(
|
||||
weapon: Required<WeaponCombatInput>,
|
||||
attributes: StatValues,
|
||||
bindings: AttributeBinding[],
|
||||
stageOrder?: CalculationStage[]
|
||||
stageOrder?: CalculationStage[],
|
||||
) {
|
||||
let damage = weapon.baseDamage;
|
||||
const order = stageOrder?.length
|
||||
? stageOrder
|
||||
: ['flat_damage', 'weapon_damage', 'final_damage'] satisfies CalculationStage[];
|
||||
: ([
|
||||
'flat_damage',
|
||||
'weapon_damage',
|
||||
'final_damage',
|
||||
] satisfies CalculationStage[]);
|
||||
for (const stage of order) {
|
||||
if (!['flat_damage', 'weapon_damage', 'final_damage'].includes(stage)) continue;
|
||||
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;
|
||||
if (stage === 'flat_damage')
|
||||
damage = (damage + values.additive) * values.multiplicative;
|
||||
else damage *= (1 + values.additive / 100) * values.multiplicative;
|
||||
}
|
||||
return damage;
|
||||
@@ -220,18 +291,22 @@ function hitValues(
|
||||
weapon: Required<WeaponCombatInput>,
|
||||
attributes: StatValues,
|
||||
bindings: AttributeBinding[],
|
||||
stageOrder?: CalculationStage[]
|
||||
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;
|
||||
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
|
||||
criticalHeadshot: normal * criticalFactor * headshotFactor,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -240,28 +315,34 @@ function dpsFor(
|
||||
attributes: StatValues,
|
||||
bindings: AttributeBinding[],
|
||||
assumptions: ResolvedAssumptions,
|
||||
stageOrder?: CalculationStage[]
|
||||
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 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 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
|
||||
bindings,
|
||||
);
|
||||
const shotsPerSecond = rpm / 60;
|
||||
return {
|
||||
values,
|
||||
burst: average * shotsPerSecond,
|
||||
sustained: average * magazine / (magazine / shotsPerSecond + reloadSeconds)
|
||||
sustained:
|
||||
(average * magazine) / (magazine / shotsPerSecond + reloadSeconds),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -270,51 +351,65 @@ export function expectedWeaponShotDamage(
|
||||
attributes: StatValues,
|
||||
bindings: AttributeBinding[],
|
||||
headshot = false,
|
||||
stageOrder?: CalculationStage[]
|
||||
stageOrder?: CalculationStage[],
|
||||
) {
|
||||
const resolvedWeapon: Required<WeaponCombatInput> = {
|
||||
baseDamage: Number(weapon.baseDamage),
|
||||
rpm: Number(weapon.rpm),
|
||||
magazine: Number(weapon.magazine),
|
||||
reloadSeconds: Number(weapon.reloadSeconds),
|
||||
headshotMultiplier: Number(weapon.headshotMultiplier ?? 2)
|
||||
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.headshot * (1 - criticalChance) +
|
||||
values.criticalHeadshot * criticalChance
|
||||
: values.normal * (1 - criticalChance) + values.critical * criticalChance;
|
||||
}
|
||||
|
||||
export function resolveWeaponFiringCycle(
|
||||
weapon: WeaponCombatInput,
|
||||
attributes: StatValues,
|
||||
bindings: AttributeBinding[]
|
||||
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,
|
||||
rpm:
|
||||
Number(weapon.rpm) *
|
||||
(1 + rateOfFire.additive / 100) *
|
||||
rateOfFire.multiplicative,
|
||||
magazine: Math.max(
|
||||
1,
|
||||
Number(weapon.magazine)
|
||||
* (1 + magazineSize.additive / 100)
|
||||
* magazineSize.multiplicative
|
||||
Number(weapon.magazine) *
|
||||
(1 + magazineSize.additive / 100) *
|
||||
magazineSize.multiplicative,
|
||||
),
|
||||
reloadSeconds: Number(weapon.reloadSeconds) / Math.max(
|
||||
reloadSeconds:
|
||||
Number(weapon.reloadSeconds) /
|
||||
Math.max(
|
||||
EPSILON,
|
||||
(1 + reloadSpeed.additive / 100) * reloadSpeed.multiplicative
|
||||
)
|
||||
(1 + reloadSpeed.additive / 100) * reloadSpeed.multiplicative,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function triggerEvents(weapon: Required<WeaponCombatInput>, 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);
|
||||
function triggerEvents(
|
||||
weapon: Required<WeaponCombatInput>,
|
||||
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;
|
||||
@@ -348,27 +443,40 @@ function triggerEvents(weapon: Required<WeaponCombatInput>, effect: StackingEffe
|
||||
|
||||
function stateAt(effect: TimedStackEffect, time: number): StackState {
|
||||
if (effect.assumptions.mode === 'off') {
|
||||
return { stacks: 0, expiresAt: 0, maxStacksReached: 0, buildUpSeconds: null };
|
||||
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
|
||||
buildUpSeconds: 0,
|
||||
};
|
||||
}
|
||||
const state: StackState = { stacks: 0, expiresAt: 0, maxStacksReached: 0, buildUpSeconds: null };
|
||||
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.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) {
|
||||
if (
|
||||
state.stacks === effect.effect.lifecycle.maxStacks &&
|
||||
state.buildUpSeconds === null
|
||||
) {
|
||||
state.buildUpSeconds = eventTime;
|
||||
}
|
||||
}
|
||||
@@ -376,13 +484,24 @@ function stateAt(effect: TimedStackEffect, time: number): StackState {
|
||||
return state;
|
||||
}
|
||||
|
||||
function stackAttributes(base: StatValues, effects: TimedStackEffect[], time: number) {
|
||||
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);
|
||||
for (const effect of effects)
|
||||
addAttributes(
|
||||
result,
|
||||
effect.effect.attributes,
|
||||
stateAt(effect, time).stacks,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function calculateCombat(input: CombatCalculationInput): CombatCalculation {
|
||||
export function calculateCombat(
|
||||
input: CombatCalculationInput,
|
||||
): CombatCalculation {
|
||||
const missing = requiredData(input);
|
||||
if (missing.length) {
|
||||
return {
|
||||
@@ -392,7 +511,7 @@ export function calculateCombat(input: CombatCalculationInput): CombatCalculatio
|
||||
burstDps: null,
|
||||
sustainedDps: null,
|
||||
encounterDps: null,
|
||||
stackSummaries: []
|
||||
stackSummaries: [],
|
||||
};
|
||||
}
|
||||
const weapon: Required<WeaponCombatInput> = {
|
||||
@@ -400,36 +519,47 @@ export function calculateCombat(input: CombatCalculationInput): CombatCalculatio
|
||||
rpm: Number(input.weapon.rpm),
|
||||
magazine: Number(input.weapon.magazine),
|
||||
reloadSeconds: Number(input.weapon.reloadSeconds),
|
||||
headshotMultiplier: Number(input.weapon.headshotMultiplier ?? 2)
|
||||
headshotMultiplier: Number(input.weapon.headshotMultiplier ?? 2),
|
||||
};
|
||||
const effects: TimedStackEffect[] = (input.stackingEffects ?? []).map((effect) => {
|
||||
const effects: TimedStackEffect[] = (input.stackingEffects ?? []).map(
|
||||
(effect) => {
|
||||
const assumptions = resolveAssumptions(input.assumptions, effect.id);
|
||||
return { effect, assumptions, events: triggerEvents(weapon, effect, assumptions) };
|
||||
});
|
||||
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)
|
||||
...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
|
||||
input.stageOrder,
|
||||
);
|
||||
let encounterDps: number | null = null;
|
||||
if (effects.some((effect) => effect.assumptions.mode === 'encounter') && finalTime > 0) {
|
||||
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 time = (finalTime * (index + 0.5)) / steps;
|
||||
const result = dpsFor(
|
||||
weapon,
|
||||
stackAttributes(input.attributes, effects, time),
|
||||
input.bindings,
|
||||
globalAssumptions,
|
||||
input.stageOrder
|
||||
input.stageOrder,
|
||||
);
|
||||
total += result.sustained * globalAssumptions.firingActivity;
|
||||
}
|
||||
@@ -444,11 +574,15 @@ export function calculateCombat(input: CombatCalculationInput): CombatCalculatio
|
||||
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;
|
||||
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;
|
||||
sum += stateAt(item, (duration * (index + 0.5)) / samples).stacks;
|
||||
}
|
||||
return {
|
||||
effectId: item.effect.id,
|
||||
@@ -457,9 +591,10 @@ export function calculateCombat(input: CombatCalculationInput): CombatCalculatio
|
||||
maxStacksReached: end.maxStacksReached,
|
||||
buildUpSeconds: end.buildUpSeconds,
|
||||
averageStacks: sum / samples,
|
||||
maintained: end.maxStacksReached === item.effect.lifecycle.maxStacks
|
||||
&& (item.assumptions.mode === 'peak' || end.stacks > 0)
|
||||
maintained:
|
||||
end.maxStacksReached === item.effect.lifecycle.maxStacks &&
|
||||
(item.assumptions.mode === 'peak' || end.stacks > 0),
|
||||
};
|
||||
})
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
validateTriggerConfiguration,
|
||||
type ConditionalEvent,
|
||||
type ConditionalProgram,
|
||||
type TriggerConfiguration
|
||||
type TriggerConfiguration,
|
||||
} from './conditional.js';
|
||||
|
||||
const modifier = {
|
||||
@@ -15,12 +15,12 @@ const modifier = {
|
||||
scope: 'equipped_weapon' as const,
|
||||
application: 'active_modifier' as const,
|
||||
attributeId: 'weapon-damage',
|
||||
value: 3
|
||||
value: 3,
|
||||
};
|
||||
|
||||
const program = (
|
||||
trigger: TriggerConfiguration,
|
||||
overrides: Partial<ConditionalProgram> = {}
|
||||
overrides: Partial<ConditionalProgram> = {},
|
||||
): ConditionalProgram => ({
|
||||
id: 'test-effect',
|
||||
trigger,
|
||||
@@ -28,385 +28,543 @@ const program = (
|
||||
cooldownSeconds: 0,
|
||||
durationSeconds: 10,
|
||||
maxStacks: 10,
|
||||
stacksPerActivation: 1
|
||||
stacksPerActivation: 1,
|
||||
},
|
||||
outcomes: [modifier],
|
||||
...overrides
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const shot = (
|
||||
at: number,
|
||||
targetId: string | null,
|
||||
hit = true,
|
||||
headshot = false
|
||||
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', {
|
||||
expect(
|
||||
validateTriggerConfiguration('same_target_hits_within_window', {
|
||||
hitsRequired: 9,
|
||||
windowSeconds: 3
|
||||
})).toMatchObject({ ok: true });
|
||||
expect(validateTriggerConfiguration('same_target_hits_within_window', {
|
||||
windowSeconds: 3,
|
||||
}),
|
||||
).toMatchObject({ ok: true });
|
||||
expect(
|
||||
validateTriggerConfiguration('same_target_hits_within_window', {
|
||||
hitsRequired: 0,
|
||||
windowSeconds: -1
|
||||
})).toEqual({
|
||||
windowSeconds: -1,
|
||||
}),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
errors: ['Hits required must be a positive integer.', 'Window seconds must be greater than 0.']
|
||||
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({
|
||||
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.']
|
||||
errors: ['Distance must be greater than 0.'],
|
||||
});
|
||||
});
|
||||
|
||||
it('generates user-facing summaries from typed values', () => {
|
||||
expect(summarizeTrigger({
|
||||
expect(
|
||||
summarizeTrigger({
|
||||
action: 'distinct_enemies_within_window',
|
||||
params: { enemiesRequired: 4, windowSeconds: 2.5 }
|
||||
})).toBe('Hit 4 distinct enemies within 2.5 seconds.');
|
||||
expect(summarizeTrigger({
|
||||
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({
|
||||
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.');
|
||||
params: { meters: 20 },
|
||||
}),
|
||||
).toBe('Hit a target within 20 meters.');
|
||||
});
|
||||
|
||||
it('validates unique health bands and their outcome relationships', () => {
|
||||
const result = validateConditionalProgram(program({
|
||||
const result = validateConditionalProgram(
|
||||
program(
|
||||
{
|
||||
action: 'health_threshold_bands',
|
||||
params: {
|
||||
bands: [
|
||||
{ thresholdPercent: 50, outcomeKey: 'low' },
|
||||
{ thresholdPercent: 20, outcomeKey: 'critical' }
|
||||
]
|
||||
}
|
||||
}, {
|
||||
{ thresholdPercent: 20, outcomeKey: 'critical' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
outcomes: [
|
||||
{ ...modifier, id: 'low', outcomeKey: 'low' },
|
||||
{ ...modifier, id: 'critical', outcomeKey: 'critical', value: 10 }
|
||||
]
|
||||
}));
|
||||
{ ...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({
|
||||
expect(
|
||||
validateConditionalProgram(
|
||||
program(
|
||||
{
|
||||
action: 'headshot',
|
||||
params: {}
|
||||
}, {
|
||||
outcomes: [{
|
||||
params: {},
|
||||
},
|
||||
{
|
||||
outcomes: [
|
||||
{
|
||||
id: 'damage',
|
||||
scope: 'target',
|
||||
application: 'direct_damage',
|
||||
basis: 'flat',
|
||||
value: -10
|
||||
}]
|
||||
}))).toEqual({
|
||||
value: -10,
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
errors: ['Instant outcome "damage" cannot use a negative value.']
|
||||
errors: ['Instant outcome "damage" cannot use a negative value.'],
|
||||
});
|
||||
expect(validateConditionalProgram(program({
|
||||
expect(
|
||||
validateConditionalProgram(
|
||||
program(
|
||||
{
|
||||
action: 'headshot',
|
||||
params: {}
|
||||
}, {
|
||||
outcomes: [{ ...modifier, value: -10 }]
|
||||
}))).toMatchObject({ ok: true });
|
||||
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({
|
||||
const result = evaluateConditionalProgram(
|
||||
program({
|
||||
action: 'same_target_hits_within_window',
|
||||
params: { hitsRequired: 3, windowSeconds: 1 }
|
||||
}), [
|
||||
params: { hitsRequired: 3, windowSeconds: 1 },
|
||||
}),
|
||||
[
|
||||
shot(0, 'alpha'),
|
||||
shot(0.2, 'bravo'),
|
||||
shot(0.4, 'alpha'),
|
||||
shot(0.8, 'alpha')
|
||||
], { mode: 'encounter', encounterSeconds: 5 });
|
||||
shot(0.8, 'alpha'),
|
||||
],
|
||||
{ mode: 'encounter', encounterSeconds: 5 },
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
status: 'OK',
|
||||
activationCount: 1,
|
||||
maxStacksReached: 1
|
||||
maxStacksReached: 1,
|
||||
});
|
||||
expect(result.activations[0]).toMatchObject({
|
||||
at: 0.8,
|
||||
stacksBefore: 0,
|
||||
stacksAfter: 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({
|
||||
const result = evaluateConditionalProgram(
|
||||
program({
|
||||
action: 'distinct_enemies_within_window',
|
||||
params: { enemiesRequired: 3, windowSeconds: 2 }
|
||||
}), [
|
||||
params: { enemiesRequired: 3, windowSeconds: 2 },
|
||||
}),
|
||||
[
|
||||
shot(0, 'alpha'),
|
||||
shot(0.2, 'alpha'),
|
||||
shot(0.4, 'bravo'),
|
||||
shot(0.6, 'charlie')
|
||||
], { mode: 'encounter', encounterSeconds: 5 });
|
||||
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({
|
||||
const result = evaluateConditionalProgram(
|
||||
program({
|
||||
action: 'consecutive_same_target_hits',
|
||||
params: { hitsRequired: 3 }
|
||||
}), [
|
||||
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 });
|
||||
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({
|
||||
const result = evaluateConditionalProgram(
|
||||
program(
|
||||
{
|
||||
action: 'headshot',
|
||||
params: {}
|
||||
}, {
|
||||
params: {},
|
||||
},
|
||||
{
|
||||
lifecycle: {
|
||||
cooldownSeconds: 3,
|
||||
durationSeconds: 5,
|
||||
maxStacks: 3,
|
||||
stacksPerActivation: 1
|
||||
}
|
||||
}), [
|
||||
stacksPerActivation: 1,
|
||||
},
|
||||
},
|
||||
),
|
||||
[
|
||||
shot(1, 'alpha', true, true),
|
||||
shot(2, 'alpha', true, true),
|
||||
shot(4, 'alpha', true, true)
|
||||
], { mode: 'encounter', encounterSeconds: 10 });
|
||||
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.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
|
||||
cooldownUntil: 7,
|
||||
});
|
||||
});
|
||||
|
||||
it('treats an explicit non-headshot hit as a body shot', () => {
|
||||
const result = evaluateConditionalProgram(program({
|
||||
const result = evaluateConditionalProgram(
|
||||
program({
|
||||
action: 'body_shot',
|
||||
params: {}
|
||||
}), [
|
||||
params: {},
|
||||
}),
|
||||
[
|
||||
shot(1, 'alpha', true, false),
|
||||
shot(2, 'alpha', true, true),
|
||||
shot(3, 'alpha', false, false)
|
||||
], { mode: 'encounter', encounterSeconds: 5 });
|
||||
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({
|
||||
const result = evaluateConditionalProgram(
|
||||
program(
|
||||
{
|
||||
action: 'damage_taken',
|
||||
params: {}
|
||||
}, {
|
||||
params: {},
|
||||
},
|
||||
{
|
||||
lifecycle: {
|
||||
cooldownSeconds: 0,
|
||||
durationSeconds: 3,
|
||||
maxStacks: 3,
|
||||
stacksPerActivation: 1
|
||||
}
|
||||
}), [
|
||||
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([
|
||||
{ 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 }
|
||||
{ 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 }
|
||||
{ start: 5.000001, end: 8, stacks: 1, outcomeKey: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it('resets firing progress on reload without removing an active buff', () => {
|
||||
const result = evaluateConditionalProgram(program({
|
||||
const result = evaluateConditionalProgram(
|
||||
program(
|
||||
{
|
||||
action: 'continuous_firing_duration',
|
||||
params: { seconds: 2 }
|
||||
}, {
|
||||
params: { seconds: 2 },
|
||||
},
|
||||
{
|
||||
lifecycle: {
|
||||
cooldownSeconds: 0,
|
||||
durationSeconds: 10,
|
||||
maxStacks: 4,
|
||||
stacksPerActivation: 1
|
||||
}
|
||||
}), [
|
||||
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 });
|
||||
{ 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({
|
||||
const result = evaluateConditionalProgram(
|
||||
program({
|
||||
action: 'continuous_firing_duration',
|
||||
params: { seconds: 2 }
|
||||
}), [
|
||||
params: { seconds: 2 },
|
||||
}),
|
||||
[
|
||||
{ kind: 'firing_started', at: 0 },
|
||||
{ kind: 'firing_stopped', at: 7 }
|
||||
], { mode: 'encounter', encounterSeconds: 10 });
|
||||
{ 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({
|
||||
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 },
|
||||
{ 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 }
|
||||
]
|
||||
}), [], {
|
||||
{ ...modifier, id: 'critical', outcomeKey: 'critical', value: 10 },
|
||||
],
|
||||
},
|
||||
),
|
||||
[],
|
||||
{
|
||||
mode: 'encounter',
|
||||
encounterSeconds: 20,
|
||||
healthPercent: 15
|
||||
});
|
||||
healthPercent: 15,
|
||||
},
|
||||
);
|
||||
expect(result.selectedOutcomeKey).toBe('critical');
|
||||
expect(result.intervals).toEqual([{ start: 0, end: 20, stacks: 1, outcomeKey: '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({
|
||||
const result = evaluateConditionalProgram(
|
||||
program(
|
||||
{
|
||||
action: 'weapon_kill',
|
||||
params: {}
|
||||
}, {
|
||||
lifecycle: { cooldownSeconds: 0, durationSeconds: 20, maxStacks: 3, stacksPerActivation: 1 },
|
||||
outcomes: [{
|
||||
params: {},
|
||||
},
|
||||
{
|
||||
lifecycle: {
|
||||
cooldownSeconds: 0,
|
||||
durationSeconds: 20,
|
||||
maxStacks: 3,
|
||||
stacksPerActivation: 1,
|
||||
},
|
||||
outcomes: [
|
||||
{
|
||||
id: 'blast',
|
||||
scope: 'target',
|
||||
application: 'direct_damage',
|
||||
basis: 'weapon_base_percent',
|
||||
value: 25
|
||||
}]
|
||||
}), [], {
|
||||
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]);
|
||||
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({
|
||||
const result = evaluateConditionalProgram(
|
||||
program({
|
||||
action: 'weapon_kill',
|
||||
params: {}
|
||||
}), [
|
||||
{ kind: 'enemy_killed', at: 4 }
|
||||
], {
|
||||
params: {},
|
||||
}),
|
||||
[{ kind: 'enemy_killed', at: 4 }],
|
||||
{
|
||||
mode: 'encounter',
|
||||
encounterSeconds: 30,
|
||||
directProcsPerMinute: 0
|
||||
});
|
||||
directProcsPerMinute: 0,
|
||||
},
|
||||
);
|
||||
expect(result.activations.map((activation) => activation.at)).toEqual([4]);
|
||||
});
|
||||
|
||||
it('models headshot kills from enemy-killed timeline events', () => {
|
||||
const result = evaluateConditionalProgram(program({
|
||||
const result = evaluateConditionalProgram(
|
||||
program({
|
||||
action: 'headshot_kill',
|
||||
params: {}
|
||||
}), [
|
||||
params: {},
|
||||
}),
|
||||
[
|
||||
{ kind: 'enemy_killed', at: 2, headshot: false },
|
||||
{ kind: 'enemy_killed', at: 7, headshot: true }
|
||||
], { mode: 'encounter', encounterSeconds: 15 });
|
||||
{ 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({
|
||||
const inRange = evaluateConditionalProgram(
|
||||
program({
|
||||
action: 'target_hit_within_distance',
|
||||
params: { meters: 20 }
|
||||
}), [
|
||||
params: { meters: 20 },
|
||||
}),
|
||||
[
|
||||
shot(1, 'alpha', false),
|
||||
shot(2, null),
|
||||
shot(3, 'alpha'),
|
||||
shot(4, 'bravo')
|
||||
], {
|
||||
shot(4, 'bravo'),
|
||||
],
|
||||
{
|
||||
mode: 'encounter',
|
||||
encounterSeconds: 10,
|
||||
distanceMeters: 20
|
||||
});
|
||||
expect(inRange.activations.map((activation) => activation.at)).toEqual([3, 4]);
|
||||
distanceMeters: 20,
|
||||
},
|
||||
);
|
||||
expect(inRange.activations.map((activation) => activation.at)).toEqual([
|
||||
3, 4,
|
||||
]);
|
||||
|
||||
const outOfRange = evaluateConditionalProgram(program({
|
||||
const outOfRange = evaluateConditionalProgram(
|
||||
program({
|
||||
action: 'target_hit_within_distance',
|
||||
params: { meters: 20 }
|
||||
}), [shot(3, 'alpha')], {
|
||||
params: { meters: 20 },
|
||||
}),
|
||||
[shot(3, 'alpha')],
|
||||
{
|
||||
mode: 'encounter',
|
||||
encounterSeconds: 10,
|
||||
distanceMeters: 21
|
||||
});
|
||||
distanceMeters: 21,
|
||||
},
|
||||
);
|
||||
expect(outOfRange.activationCount).toBe(0);
|
||||
});
|
||||
|
||||
it('requires a target-distance assumption for distance-limited hit events', () => {
|
||||
const result = evaluateConditionalProgram(program({
|
||||
const result = evaluateConditionalProgram(
|
||||
program({
|
||||
action: 'target_hit_within_distance',
|
||||
params: { meters: 20 }
|
||||
}), [shot(3, 'alpha')], {
|
||||
params: { meters: 20 },
|
||||
}),
|
||||
[shot(3, 'alpha')],
|
||||
{
|
||||
mode: 'encounter',
|
||||
encounterSeconds: 10
|
||||
});
|
||||
encounterSeconds: 10,
|
||||
},
|
||||
);
|
||||
expect(result.activationCount).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps target proximity as a persistent condition rather than a hit event', () => {
|
||||
const result = evaluateConditionalProgram(program({
|
||||
const result = evaluateConditionalProgram(
|
||||
program({
|
||||
action: 'within_distance',
|
||||
params: { meters: 20 }
|
||||
}), [], {
|
||||
params: { meters: 20 },
|
||||
}),
|
||||
[],
|
||||
{
|
||||
mode: 'encounter',
|
||||
encounterSeconds: 10,
|
||||
distanceMeters: 20
|
||||
});
|
||||
distanceMeters: 20,
|
||||
},
|
||||
);
|
||||
expect(result.activationCount).toBe(1);
|
||||
expect(result.intervals).toEqual([{ start: 0, end: 10, stacks: 10, outcomeKey: null }]);
|
||||
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({
|
||||
const result = evaluateConditionalProgram(
|
||||
program({
|
||||
action: 'while_firing',
|
||||
params: {}
|
||||
}), [
|
||||
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 });
|
||||
{ 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 }
|
||||
{ start: 5, end: 8, stacks: 10, outcomeKey: null },
|
||||
]);
|
||||
expect(result.uptimePercent).toBe(50);
|
||||
});
|
||||
|
||||
+346
-158
@@ -14,7 +14,7 @@ export const TRIGGER_ACTION_KEYS = [
|
||||
'weapon_swap',
|
||||
'reload',
|
||||
'within_distance',
|
||||
'extra_health_active'
|
||||
'extra_health_active',
|
||||
] as const;
|
||||
|
||||
export type TriggerActionKey = (typeof TRIGGER_ACTION_KEYS)[number];
|
||||
@@ -68,7 +68,7 @@ export type TriggerConfiguration = {
|
||||
[K in TriggerActionKey]: {
|
||||
action: K;
|
||||
params: TriggerParamsByAction[K];
|
||||
}
|
||||
};
|
||||
}[TriggerActionKey];
|
||||
|
||||
export type EffectScope =
|
||||
@@ -103,7 +103,8 @@ export type RestorationOutcome = {
|
||||
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;
|
||||
@@ -154,13 +155,14 @@ export type ConditionalAssumptions = {
|
||||
};
|
||||
|
||||
export type ValidationResult<T> =
|
||||
| { 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<TriggerParamsByAction[TriggerActionKey]>;
|
||||
validate: (
|
||||
params: unknown,
|
||||
) => ValidationResult<TriggerParamsByAction[TriggerActionKey]>;
|
||||
summarize: (params: TriggerParamsByAction[TriggerActionKey]) => string;
|
||||
};
|
||||
|
||||
@@ -208,68 +210,102 @@ export type ConditionalEvaluation = {
|
||||
};
|
||||
|
||||
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<string, unknown> =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
const clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, 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)));
|
||||
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<EmptyTriggerParams> {
|
||||
if (params === undefined || (isObject(params) && Object.keys(params).length === 0)) {
|
||||
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<SameTargetHitsParams> {
|
||||
function validateSameTarget(
|
||||
params: unknown,
|
||||
): ValidationResult<SameTargetHitsParams> {
|
||||
const errors: string[] = [];
|
||||
if (!isObject(params)) return { ok: false, errors: ['Trigger parameters must be an object.'] };
|
||||
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) } };
|
||||
: {
|
||||
ok: true,
|
||||
value: {
|
||||
hitsRequired: Number(params.hitsRequired),
|
||||
windowSeconds: Number(params.windowSeconds),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function validateDistinctEnemies(params: unknown): ValidationResult<DistinctEnemiesParams> {
|
||||
function validateDistinctEnemies(
|
||||
params: unknown,
|
||||
): ValidationResult<DistinctEnemiesParams> {
|
||||
const errors: string[] = [];
|
||||
if (!isObject(params)) return { ok: false, errors: ['Trigger parameters must be an object.'] };
|
||||
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) } };
|
||||
: {
|
||||
ok: true,
|
||||
value: {
|
||||
enemiesRequired: Number(params.enemiesRequired),
|
||||
windowSeconds: Number(params.windowSeconds),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function validateConsecutive(params: unknown): ValidationResult<ConsecutiveHitsParams> {
|
||||
function validateConsecutive(
|
||||
params: unknown,
|
||||
): ValidationResult<ConsecutiveHitsParams> {
|
||||
const errors: string[] = [];
|
||||
if (!isObject(params)) return { ok: false, errors: ['Trigger parameters must be an object.'] };
|
||||
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<ContinuousFiringParams> {
|
||||
function validateContinuousFiring(
|
||||
params: unknown,
|
||||
): ValidationResult<ContinuousFiringParams> {
|
||||
const errors: string[] = [];
|
||||
if (!isObject(params)) return { ok: false, errors: ['Trigger parameters must be an object.'] };
|
||||
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<HealthThresholdParams> {
|
||||
function validateHealthBands(
|
||||
params: unknown,
|
||||
): ValidationResult<HealthThresholdParams> {
|
||||
const errors: string[] = [];
|
||||
if (!isObject(params) || !Array.isArray(params.bands)) {
|
||||
return { ok: false, errors: ['Health bands must be an array.'] };
|
||||
@@ -286,27 +322,43 @@ function validateHealthBands(params: unknown): ValidationResult<HealthThresholdP
|
||||
continue;
|
||||
}
|
||||
const threshold = candidate.thresholdPercent;
|
||||
const outcomeKey = typeof candidate.outcomeKey === 'string' ? candidate.outcomeKey.trim() : '';
|
||||
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.`);
|
||||
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 (!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.`);
|
||||
errors.push(
|
||||
`Health threshold ${formatNumber(threshold)}% is duplicated.`,
|
||||
);
|
||||
}
|
||||
if (outcomeKey && outcomeKeys.has(outcomeKey)) errors.push(`Outcome key "${outcomeKey}" 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 });
|
||||
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) } };
|
||||
: {
|
||||
ok: true,
|
||||
value: {
|
||||
bands: bands.sort((a, b) => a.thresholdPercent - b.thresholdPercent),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function validateDistance(params: unknown): ValidationResult<DistanceParams> {
|
||||
const errors: string[] = [];
|
||||
if (!isObject(params)) return { ok: false, errors: ['Trigger parameters must be an object.'] };
|
||||
if (!isObject(params))
|
||||
return { ok: false, errors: ['Trigger parameters must be an object.'] };
|
||||
positiveNumber(params.meters, 'Distance', errors);
|
||||
return errors.length
|
||||
? { ok: false, errors }
|
||||
@@ -315,7 +367,9 @@ function validateDistance(params: unknown): ValidationResult<DistanceParams> {
|
||||
|
||||
const emptySummary = () => '';
|
||||
|
||||
export const TRIGGER_ACTION_REGISTRY: Readonly<Record<TriggerActionKey, TriggerActionDefinition>> = {
|
||||
export const TRIGGER_ACTION_REGISTRY: Readonly<
|
||||
Record<TriggerActionKey, TriggerActionDefinition>
|
||||
> = {
|
||||
same_target_hits_within_window: {
|
||||
key: 'same_target_hits_within_window',
|
||||
label: 'Same-target hits within a window',
|
||||
@@ -323,7 +377,7 @@ export const TRIGGER_ACTION_REGISTRY: Readonly<Record<TriggerActionKey, TriggerA
|
||||
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',
|
||||
@@ -332,7 +386,7 @@ export const TRIGGER_ACTION_REGISTRY: Readonly<Record<TriggerActionKey, TriggerA
|
||||
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',
|
||||
@@ -341,7 +395,7 @@ export const TRIGGER_ACTION_REGISTRY: Readonly<Record<TriggerActionKey, TriggerA
|
||||
summarize: (params) => {
|
||||
const value = params as ConsecutiveHitsParams;
|
||||
return `Hit the same enemy ${plural(value.hitsRequired, 'consecutive time')}.`;
|
||||
}
|
||||
},
|
||||
},
|
||||
continuous_firing_duration: {
|
||||
key: 'continuous_firing_duration',
|
||||
@@ -350,13 +404,13 @@ export const TRIGGER_ACTION_REGISTRY: Readonly<Record<TriggerActionKey, TriggerA
|
||||
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.'
|
||||
summarize: () => 'While firing.',
|
||||
},
|
||||
health_threshold_bands: {
|
||||
key: 'health_threshold_bands',
|
||||
@@ -366,33 +420,36 @@ export const TRIGGER_ACTION_REGISTRY: Readonly<Record<TriggerActionKey, TriggerA
|
||||
const value = params as HealthThresholdParams;
|
||||
const thresholds = [...value.bands]
|
||||
.sort((a, b) => b.thresholdPercent - a.thresholdPercent)
|
||||
.map((band) => `${formatNumber(band.thresholdPercent)}% (${band.outcomeKey})`);
|
||||
.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.'
|
||||
summarize: () => 'Land a headshot.',
|
||||
},
|
||||
body_shot: {
|
||||
key: 'body_shot',
|
||||
label: 'Body shot',
|
||||
validate: validateEmpty,
|
||||
summarize: () => 'Land a non-headshot weapon hit.'
|
||||
summarize: () => 'Land a non-headshot weapon hit.',
|
||||
},
|
||||
headshot_kill: {
|
||||
key: 'headshot_kill',
|
||||
label: 'Headshot kill',
|
||||
validate: validateEmpty,
|
||||
summarize: () => 'Kill an enemy with a headshot.'
|
||||
summarize: () => 'Kill an enemy with a headshot.',
|
||||
},
|
||||
weapon_kill: {
|
||||
key: 'weapon_kill',
|
||||
label: 'Weapon kill',
|
||||
validate: validateEmpty,
|
||||
summarize: () => 'Kill an enemy with a weapon.'
|
||||
summarize: () => 'Kill an enemy with a weapon.',
|
||||
},
|
||||
target_hit_within_distance: {
|
||||
key: 'target_hit_within_distance',
|
||||
@@ -401,25 +458,25 @@ export const TRIGGER_ACTION_REGISTRY: Readonly<Record<TriggerActionKey, TriggerA
|
||||
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.'
|
||||
summarize: () => 'Take damage.',
|
||||
},
|
||||
weapon_swap: {
|
||||
key: 'weapon_swap',
|
||||
label: 'Weapon swap',
|
||||
validate: validateEmpty,
|
||||
summarize: () => 'Swap weapons.'
|
||||
summarize: () => 'Swap weapons.',
|
||||
},
|
||||
reload: {
|
||||
key: 'reload',
|
||||
label: 'Reload',
|
||||
validate: validateEmpty,
|
||||
summarize: () => 'Start reloading.'
|
||||
summarize: () => 'Start reloading.',
|
||||
},
|
||||
within_distance: {
|
||||
key: 'within_distance',
|
||||
@@ -428,81 +485,125 @@ export const TRIGGER_ACTION_REGISTRY: Readonly<Record<TriggerActionKey, TriggerA
|
||||
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.'
|
||||
}
|
||||
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
|
||||
params: unknown,
|
||||
): ValidationResult<TriggerConfiguration> {
|
||||
const result = TRIGGER_ACTION_REGISTRY[action].validate(params);
|
||||
if (!result.ok) return result;
|
||||
return { ok: true, value: { action, params: result.value } as TriggerConfiguration };
|
||||
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<ConditionalProgram> {
|
||||
export function validateConditionalProgram(
|
||||
program: ConditionalProgram,
|
||||
): ValidationResult<ConditionalProgram> {
|
||||
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 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) {
|
||||
if (
|
||||
!finiteNumber(lifecycle.cooldownSeconds) ||
|
||||
lifecycle.cooldownSeconds < 0
|
||||
) {
|
||||
errors.push('Cooldown seconds must be 0 or greater.');
|
||||
}
|
||||
if (!finiteNumber(lifecycle.durationSeconds) || lifecycle.durationSeconds < 0) {
|
||||
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);
|
||||
positiveInteger(
|
||||
lifecycle.stacksPerActivation,
|
||||
'Stacks per activation',
|
||||
errors,
|
||||
);
|
||||
if (
|
||||
Number.isInteger(lifecycle.maxStacks)
|
||||
&& Number.isInteger(lifecycle.stacksPerActivation)
|
||||
&& lifecycle.stacksPerActivation > lifecycle.maxStacks
|
||||
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<string>();
|
||||
const healthOutcomeKeys = program.trigger.action === 'health_threshold_bands'
|
||||
? new Set((program.trigger.params as HealthThresholdParams).bands.map((band) => band.outcomeKey))
|
||||
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.`);
|
||||
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 (!finiteNumber(outcome.value))
|
||||
errors.push(`Outcome "${outcome.id}" requires a finite value.`);
|
||||
if (
|
||||
outcome.application !== 'active_modifier'
|
||||
&& finiteNumber(outcome.value)
|
||||
&& outcome.value < 0
|
||||
outcome.application !== 'active_modifier' &&
|
||||
finiteNumber(outcome.value) &&
|
||||
outcome.value < 0
|
||||
) {
|
||||
errors.push(`Instant outcome "${outcome.id}" cannot use a negative value.`);
|
||||
errors.push(
|
||||
`Instant outcome "${outcome.id}" cannot use a negative value.`,
|
||||
);
|
||||
}
|
||||
if (outcome.application === 'active_modifier' && !outcome.attributeId.trim()) {
|
||||
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 &&
|
||||
(!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));
|
||||
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.`);
|
||||
if (!provided.has(key))
|
||||
errors.push(`Health-band outcome "${key}" has no configured effect.`);
|
||||
}
|
||||
}
|
||||
return errors.length ? { ok: false, errors } : { ok: true, value: program };
|
||||
@@ -514,12 +615,21 @@ 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)
|
||||
.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[] {
|
||||
function firingSegments(
|
||||
events: ConditionalEvent[],
|
||||
encounterSeconds: number,
|
||||
): FiringSegment[] {
|
||||
const segments: FiringSegment[] = [];
|
||||
let startedAt: number | null = null;
|
||||
for (const event of events) {
|
||||
@@ -531,19 +641,23 @@ function firingSegments(events: ConditionalEvent[], encounterSeconds: number): F
|
||||
startedAt = event.at;
|
||||
continue;
|
||||
}
|
||||
if ((event.kind === 'firing_stopped' || event.kind === 'reload_started') && startedAt !== null) {
|
||||
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 });
|
||||
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
|
||||
assumptions: ConditionalAssumptions,
|
||||
): CandidateActivation[] {
|
||||
switch (trigger.action) {
|
||||
case 'same_target_hits_within_window': {
|
||||
@@ -553,7 +667,7 @@ function eventCandidates(
|
||||
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
|
||||
(at) => event.at - at <= params.windowSeconds + EPSILON,
|
||||
);
|
||||
recent.push(event.at);
|
||||
if (recent.length >= params.hitsRequired) {
|
||||
@@ -572,7 +686,9 @@ function eventCandidates(
|
||||
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].filter(
|
||||
([, at]) => event.at - at <= params.windowSeconds + EPSILON,
|
||||
),
|
||||
);
|
||||
recent.set(event.targetId, event.at);
|
||||
if (recent.size >= params.enemiesRequired) {
|
||||
@@ -609,23 +725,35 @@ function eventCandidates(
|
||||
}
|
||||
case 'continuous_firing_duration': {
|
||||
return firingSegments(events, assumptions.encounterSeconds)
|
||||
.filter((segment) => segment.end - segment.start + EPSILON >= trigger.params.seconds)
|
||||
.filter(
|
||||
(segment) =>
|
||||
segment.end - segment.start + EPSILON >= trigger.params.seconds,
|
||||
)
|
||||
.map((segment) => ({
|
||||
at: segment.start + trigger.params.seconds,
|
||||
outcomeKey: null
|
||||
outcomeKey: null,
|
||||
}));
|
||||
}
|
||||
case 'headshot':
|
||||
return events
|
||||
.filter((event): event is ShotEvent => event.kind === 'shot' && event.hit && event.headshot === true)
|
||||
.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)
|
||||
.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)
|
||||
.filter(
|
||||
(event): event is EnemyKilledEvent =>
|
||||
event.kind === 'enemy_killed' && event.headshot === true,
|
||||
)
|
||||
.map((event) => ({ at: event.at, outcomeKey: null }));
|
||||
case 'weapon_kill':
|
||||
return events
|
||||
@@ -633,12 +761,14 @@ function eventCandidates(
|
||||
.map((event) => ({ at: event.at, outcomeKey: null }));
|
||||
case 'target_hit_within_distance':
|
||||
if (
|
||||
!finiteNumber(assumptions.distanceMeters)
|
||||
|| assumptions.distanceMeters > trigger.params.meters + EPSILON
|
||||
) return [];
|
||||
!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)
|
||||
.filter(
|
||||
(event): event is ShotEvent =>
|
||||
event.kind === 'shot' && event.hit && Boolean(event.targetId),
|
||||
)
|
||||
.map((event) => ({ at: event.at, outcomeKey: null }));
|
||||
case 'damage_taken':
|
||||
@@ -661,7 +791,10 @@ function eventCandidates(
|
||||
}
|
||||
}
|
||||
|
||||
function directRateCandidates(procsPerMinute: number, encounterSeconds: number): CandidateActivation[] {
|
||||
function directRateCandidates(
|
||||
procsPerMinute: number,
|
||||
encounterSeconds: number,
|
||||
): CandidateActivation[] {
|
||||
if (!finiteNumber(procsPerMinute) || procsPerMinute <= 0) return [];
|
||||
const interval = 60 / procsPerMinute;
|
||||
const candidates: CandidateActivation[] = [];
|
||||
@@ -671,31 +804,44 @@ function directRateCandidates(procsPerMinute: number, encounterSeconds: number):
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function strongestHealthOutcome(params: HealthThresholdParams, healthPercent: number | undefined) {
|
||||
function strongestHealthOutcome(
|
||||
params: HealthThresholdParams,
|
||||
healthPercent: number | undefined,
|
||||
) {
|
||||
if (!finiteNumber(healthPercent)) return null;
|
||||
const current = clamp(healthPercent, 0, 100);
|
||||
return [...params.bands]
|
||||
return (
|
||||
[...params.bands]
|
||||
.sort((left, right) => left.thresholdPercent - right.thresholdPercent)
|
||||
.find((band) => current <= band.thresholdPercent + EPSILON)?.outcomeKey ?? null;
|
||||
.find((band) => current <= band.thresholdPercent + EPSILON)?.outcomeKey ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function persistentCondition(
|
||||
trigger: TriggerConfiguration,
|
||||
assumptions: ConditionalAssumptions
|
||||
assumptions: ConditionalAssumptions,
|
||||
): { active: boolean; outcomeKey: string | null } {
|
||||
switch (trigger.action) {
|
||||
case 'health_threshold_bands': {
|
||||
const outcomeKey = strongestHealthOutcome(trigger.params, assumptions.healthPercent);
|
||||
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
|
||||
active:
|
||||
finiteNumber(assumptions.distanceMeters) &&
|
||||
assumptions.distanceMeters <= trigger.params.meters + EPSILON,
|
||||
outcomeKey: null,
|
||||
};
|
||||
case 'extra_health_active':
|
||||
return { active: assumptions.extraHealthActive === true, outcomeKey: null };
|
||||
return {
|
||||
active: assumptions.extraHealthActive === true,
|
||||
outcomeKey: null,
|
||||
};
|
||||
default:
|
||||
return { active: false, outcomeKey: null };
|
||||
}
|
||||
@@ -705,11 +851,13 @@ function outcomeEventsAt(
|
||||
program: ConditionalProgram,
|
||||
at: number,
|
||||
stacks: number,
|
||||
outcomeKey: string | null
|
||||
outcomeKey: string | null,
|
||||
): ConditionalOutcomeEvent[] {
|
||||
return program.outcomes
|
||||
.filter((outcome) => outcome.application !== 'active_modifier')
|
||||
.filter((outcome) => outcomeKey === null || outcome.outcomeKey === outcomeKey)
|
||||
.filter(
|
||||
(outcome) => outcomeKey === null || outcome.outcomeKey === outcomeKey,
|
||||
)
|
||||
.map((outcome) => ({
|
||||
at,
|
||||
effectiveAfter: at + EPSILON,
|
||||
@@ -718,11 +866,14 @@ function outcomeEventsAt(
|
||||
scope: outcome.scope,
|
||||
value: outcome.value * stacks,
|
||||
basis: outcome.application === 'direct_damage' ? outcome.basis : null,
|
||||
stacks
|
||||
stacks,
|
||||
}));
|
||||
}
|
||||
|
||||
function summarizeIntervals(intervals: ConditionalStateInterval[], encounterSeconds: number) {
|
||||
function summarizeIntervals(
|
||||
intervals: ConditionalStateInterval[],
|
||||
encounterSeconds: number,
|
||||
) {
|
||||
if (encounterSeconds <= 0) return { averageStacks: 0, uptimePercent: 0 };
|
||||
let stackSeconds = 0;
|
||||
let activeSeconds = 0;
|
||||
@@ -733,13 +884,13 @@ function summarizeIntervals(intervals: ConditionalStateInterval[], encounterSeco
|
||||
}
|
||||
return {
|
||||
averageStacks: stackSeconds / encounterSeconds,
|
||||
uptimePercent: clamp(activeSeconds / encounterSeconds * 100, 0, 100)
|
||||
uptimePercent: clamp((activeSeconds / encounterSeconds) * 100, 0, 100),
|
||||
};
|
||||
}
|
||||
|
||||
function stateIntervals(
|
||||
activations: ConditionalActivation[],
|
||||
encounterSeconds: number
|
||||
encounterSeconds: number,
|
||||
): ConditionalStateInterval[] {
|
||||
const boundaries = new Set<number>([0, encounterSeconds]);
|
||||
for (const activation of activations) {
|
||||
@@ -754,21 +905,27 @@ function stateIntervals(
|
||||
if (end - start <= EPSILON) continue;
|
||||
const active = [...activations]
|
||||
.reverse()
|
||||
.find((activation) =>
|
||||
activation.effectiveAfter <= start + EPSILON
|
||||
&& activation.expiresAt > start + EPSILON
|
||||
.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 &&
|
||||
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 });
|
||||
intervals.push({
|
||||
start,
|
||||
end,
|
||||
stacks: active.stacksAfter,
|
||||
outcomeKey: active.outcomeKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
return intervals;
|
||||
@@ -777,21 +934,23 @@ function stateIntervals(
|
||||
function evaluateCandidates(
|
||||
program: ConditionalProgram,
|
||||
candidates: CandidateActivation[],
|
||||
encounterSeconds: number
|
||||
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)) {
|
||||
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
|
||||
stacks + program.lifecycle.stacksPerActivation,
|
||||
);
|
||||
expiresAt = candidate.at + program.lifecycle.durationSeconds;
|
||||
cooldownUntil = candidate.at + program.lifecycle.cooldownSeconds;
|
||||
@@ -802,10 +961,12 @@ function evaluateCandidates(
|
||||
stacksAfter: stacks,
|
||||
expiresAt,
|
||||
cooldownUntil,
|
||||
outcomeKey: candidate.outcomeKey
|
||||
outcomeKey: candidate.outcomeKey,
|
||||
};
|
||||
activations.push(activation);
|
||||
outcomeEvents.push(...outcomeEventsAt(program, candidate.at, stacks, candidate.outcomeKey));
|
||||
outcomeEvents.push(
|
||||
...outcomeEventsAt(program, candidate.at, stacks, candidate.outcomeKey),
|
||||
);
|
||||
}
|
||||
return { activations, outcomeEvents };
|
||||
}
|
||||
@@ -813,7 +974,7 @@ function evaluateCandidates(
|
||||
function evaluatePersistent(
|
||||
program: ConditionalProgram,
|
||||
assumptions: ConditionalAssumptions,
|
||||
outcomeKey: string | null
|
||||
outcomeKey: string | null,
|
||||
) {
|
||||
const stacks = program.lifecycle.maxStacks;
|
||||
const activation: ConditionalActivation = {
|
||||
@@ -823,22 +984,23 @@ function evaluatePersistent(
|
||||
stacksAfter: stacks,
|
||||
expiresAt: assumptions.encounterSeconds,
|
||||
cooldownUntil: 0,
|
||||
outcomeKey
|
||||
outcomeKey,
|
||||
};
|
||||
const intervals = assumptions.encounterSeconds > 0
|
||||
const intervals =
|
||||
assumptions.encounterSeconds > 0
|
||||
? [{ start: 0, end: assumptions.encounterSeconds, stacks, outcomeKey }]
|
||||
: [];
|
||||
return {
|
||||
activations: [activation],
|
||||
intervals,
|
||||
outcomeEvents: outcomeEventsAt(program, 0, stacks, outcomeKey)
|
||||
outcomeEvents: outcomeEventsAt(program, 0, stacks, outcomeKey),
|
||||
};
|
||||
}
|
||||
|
||||
function evaluateWhileFiring(
|
||||
program: ConditionalProgram,
|
||||
events: ConditionalEvent[],
|
||||
assumptions: ConditionalAssumptions
|
||||
assumptions: ConditionalAssumptions,
|
||||
) {
|
||||
const segments = firingSegments(events, assumptions.encounterSeconds);
|
||||
const stacks = program.lifecycle.maxStacks;
|
||||
@@ -849,7 +1011,7 @@ function evaluateWhileFiring(
|
||||
stacksAfter: stacks,
|
||||
expiresAt: segment.end,
|
||||
cooldownUntil: segment.start,
|
||||
outcomeKey: null
|
||||
outcomeKey: null,
|
||||
}));
|
||||
return {
|
||||
activations,
|
||||
@@ -857,15 +1019,19 @@ function evaluateWhileFiring(
|
||||
start: segment.start,
|
||||
end: segment.end,
|
||||
stacks,
|
||||
outcomeKey: null
|
||||
outcomeKey: null,
|
||||
})),
|
||||
outcomeEvents: activations.flatMap((activation) =>
|
||||
outcomeEventsAt(program, activation.at, stacks, null)
|
||||
)
|
||||
outcomeEventsAt(program, activation.at, stacks, null),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function invalidEvaluation(programId: string, mode: ConditionalMode, errors: string[]): ConditionalEvaluation {
|
||||
function invalidEvaluation(
|
||||
programId: string,
|
||||
mode: ConditionalMode,
|
||||
errors: string[],
|
||||
): ConditionalEvaluation {
|
||||
return {
|
||||
status: 'INVALID',
|
||||
errors,
|
||||
@@ -878,37 +1044,40 @@ function invalidEvaluation(programId: string, mode: ConditionalMode, errors: str
|
||||
maxStacksReached: 0,
|
||||
averageStacks: 0,
|
||||
uptimePercent: 0,
|
||||
selectedOutcomeKey: null
|
||||
selectedOutcomeKey: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function evaluateConditionalProgram(
|
||||
program: ConditionalProgram,
|
||||
events: ConditionalEvent[],
|
||||
assumptions: ConditionalAssumptions
|
||||
assumptions: ConditionalAssumptions,
|
||||
): ConditionalEvaluation {
|
||||
const validation = validateConditionalProgram(program);
|
||||
const assumptionErrors: string[] = [];
|
||||
if (!finiteNumber(assumptions.encounterSeconds) || assumptions.encounterSeconds <= 0) {
|
||||
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)
|
||||
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]
|
||||
);
|
||||
return invalidEvaluation(program.id, assumptions.mode, [
|
||||
...(validation.ok ? [] : validation.errors),
|
||||
...assumptionErrors,
|
||||
]);
|
||||
}
|
||||
if (assumptions.mode === 'off') {
|
||||
return {
|
||||
...invalidEvaluation(program.id, assumptions.mode, []),
|
||||
status: 'OK'
|
||||
status: 'OK',
|
||||
};
|
||||
}
|
||||
const ordered = orderedEvents(events, assumptions.encounterSeconds);
|
||||
@@ -918,22 +1087,34 @@ export function evaluateConditionalProgram(
|
||||
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
|
||||
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);
|
||||
const persistent = evaluatePersistent(
|
||||
program,
|
||||
assumptions,
|
||||
selectedOutcomeKey,
|
||||
);
|
||||
({ activations, intervals, outcomeEvents } = persistent);
|
||||
} else if (
|
||||
finiteNumber(assumptions.directProcsPerMinute)
|
||||
&& assumptions.directProcsPerMinute > 0
|
||||
finiteNumber(assumptions.directProcsPerMinute) &&
|
||||
assumptions.directProcsPerMinute > 0
|
||||
) {
|
||||
const evaluated = evaluateCandidates(
|
||||
program,
|
||||
directRateCandidates(assumptions.directProcsPerMinute, assumptions.encounterSeconds),
|
||||
assumptions.encounterSeconds
|
||||
directRateCandidates(
|
||||
assumptions.directProcsPerMinute,
|
||||
assumptions.encounterSeconds,
|
||||
),
|
||||
assumptions.encounterSeconds,
|
||||
);
|
||||
activations = evaluated.activations;
|
||||
outcomeEvents = evaluated.outcomeEvents;
|
||||
@@ -945,13 +1126,17 @@ export function evaluateConditionalProgram(
|
||||
const condition = persistentCondition(program.trigger, assumptions);
|
||||
if (condition.active) {
|
||||
selectedOutcomeKey = condition.outcomeKey;
|
||||
const persistent = evaluatePersistent(program, assumptions, 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
|
||||
assumptions.encounterSeconds,
|
||||
);
|
||||
activations = evaluated.activations;
|
||||
outcomeEvents = evaluated.outcomeEvents;
|
||||
@@ -968,9 +1153,12 @@ export function evaluateConditionalProgram(
|
||||
intervals,
|
||||
outcomeEvents,
|
||||
activationCount: activations.length,
|
||||
maxStacksReached: Math.max(0, ...activations.map((activation) => activation.stacksAfter)),
|
||||
maxStacksReached: Math.max(
|
||||
0,
|
||||
...activations.map((activation) => activation.stacksAfter),
|
||||
),
|
||||
averageStacks: summary.averageStacks,
|
||||
uptimePercent: summary.uptimePercent,
|
||||
selectedOutcomeKey
|
||||
selectedOutcomeKey,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,14 +8,15 @@ import {
|
||||
registeredEventFields,
|
||||
readStatePoolThreshold,
|
||||
statePoolThresholdPredicate,
|
||||
type ConditionalMechanicDraftV1
|
||||
type ConditionalMechanicDraftV1,
|
||||
} from './mechanic-editor.js';
|
||||
|
||||
const draft = (): ConditionalMechanicDraftV1 => ({
|
||||
version: 1,
|
||||
simulationReadiness: 'ready',
|
||||
scenarioInputs: [],
|
||||
pools: [{
|
||||
pools: [
|
||||
{
|
||||
key: 'effect',
|
||||
label: 'Effect',
|
||||
stateKind: 'stack',
|
||||
@@ -24,9 +25,11 @@ const draft = (): ConditionalMechanicDraftV1 => ({
|
||||
min: 0,
|
||||
max: 10,
|
||||
bestCaseValue: 10,
|
||||
expiresAfterSeconds: 10
|
||||
}],
|
||||
clauses: [{
|
||||
expiresAfterSeconds: 10,
|
||||
},
|
||||
],
|
||||
clauses: [
|
||||
{
|
||||
key: 'gain-effect',
|
||||
label: 'Gain Effect',
|
||||
event: 'weapon_hit',
|
||||
@@ -36,12 +39,13 @@ const draft = (): ConditionalMechanicDraftV1 => ({
|
||||
kind: 'sliding_count',
|
||||
threshold: 9,
|
||||
windowSeconds: 3,
|
||||
groupBy: { source: 'event', path: 'targetId' }
|
||||
groupBy: { source: 'event', path: 'targetId' },
|
||||
},
|
||||
cooldownSeconds: 2,
|
||||
weight: 1,
|
||||
mutations: [{ kind: 'add', pool: 'effect', value: 1 }],
|
||||
outcomes: [{
|
||||
outcomes: [
|
||||
{
|
||||
key: 'weapon-damage',
|
||||
label: 'Weapon Damage',
|
||||
phase: 'while_active',
|
||||
@@ -54,10 +58,12 @@ const draft = (): ConditionalMechanicDraftV1 => ({
|
||||
descriptionSlot: 1,
|
||||
variants: {
|
||||
I: { value: 3 },
|
||||
II: { value: 4.2 }
|
||||
}
|
||||
}]
|
||||
}]
|
||||
II: { value: 4.2 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
describe('conditional mechanic authoring payload', () => {
|
||||
@@ -66,71 +72,112 @@ describe('conditional mechanic authoring payload', () => {
|
||||
value.pools[0] = {
|
||||
...value.pools[0],
|
||||
expiresAfterSeconds: undefined,
|
||||
expiresAfterSecondsVariants: { I: 12, II: 18 }
|
||||
expiresAfterSecondsVariants: { I: 12, II: 18 },
|
||||
};
|
||||
const result = parseConditionalMechanicDraft(value, { variantKeys: ['I', 'II'] });
|
||||
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'] });
|
||||
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.'
|
||||
'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 });
|
||||
const predicate = currentHealthPercentBandPredicate({
|
||||
minimum: 25,
|
||||
maximum: 50,
|
||||
});
|
||||
expect(readCurrentHealthPercentBand(predicate)).toEqual({
|
||||
minimum: 25,
|
||||
maximum: 50,
|
||||
});
|
||||
expect(predicate).toEqual({
|
||||
kind: 'all',
|
||||
predicates: [{
|
||||
predicates: [
|
||||
{
|
||||
kind: 'compare',
|
||||
left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' },
|
||||
left: {
|
||||
source: 'combat',
|
||||
path: 'currentHealthPercent',
|
||||
timing: 'current',
|
||||
},
|
||||
operator: 'gte',
|
||||
right: 25
|
||||
}, {
|
||||
right: 25,
|
||||
},
|
||||
{
|
||||
kind: 'compare',
|
||||
left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' },
|
||||
left: {
|
||||
source: 'combat',
|
||||
path: 'currentHealthPercent',
|
||||
timing: 'current',
|
||||
},
|
||||
operator: 'lt',
|
||||
right: 50
|
||||
}]
|
||||
right: 50,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(readCurrentHealthPercentBand({
|
||||
expect(
|
||||
readCurrentHealthPercentBand({
|
||||
kind: 'compare',
|
||||
left: { source: 'event', path: 'values.healthPercent' },
|
||||
operator: 'lt',
|
||||
right: 50
|
||||
})).toBeNull();
|
||||
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 comparison = {
|
||||
path: 'values.state',
|
||||
operator: 'eq' as const,
|
||||
value,
|
||||
};
|
||||
const predicate = eventFieldComparisonPredicate(comparison);
|
||||
expect(readEventFieldComparison(predicate)).toEqual(comparison);
|
||||
}
|
||||
expect(readEventFieldComparison({
|
||||
expect(
|
||||
readEventFieldComparison({
|
||||
kind: 'compare',
|
||||
left: { source: 'event', path: 'values.state' },
|
||||
operator: 'eq',
|
||||
right: { source: 'event', path: 'values.previousState' }
|
||||
})).toBeNull();
|
||||
expect(readEventFieldComparison({
|
||||
right: { source: 'event', path: 'values.previousState' },
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
readEventFieldComparison({
|
||||
kind: 'all',
|
||||
predicates: []
|
||||
})).toBeNull();
|
||||
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 }
|
||||
{
|
||||
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';
|
||||
@@ -139,17 +186,17 @@ describe('conditional mechanic authoring payload', () => {
|
||||
value.clauses[0].if = eventFieldComparisonPredicate({
|
||||
path: 'values.hasExtraHealth',
|
||||
operator: 'eq',
|
||||
value: true
|
||||
value: true,
|
||||
});
|
||||
const result = parseConditionalMechanicDraft(value, {
|
||||
variantKeys: ['I', 'II'],
|
||||
allowedAttributeIds: new Set(['weapon-damage'])
|
||||
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
|
||||
if: value.clauses[0].if,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -161,17 +208,19 @@ describe('conditional mechanic authoring payload', () => {
|
||||
value.clauses[0].if = eventFieldComparisonPredicate({
|
||||
path: 'values.extraHealthActive',
|
||||
operator: 'eq',
|
||||
value: true
|
||||
value: true,
|
||||
});
|
||||
const result = parseConditionalMechanicDraft(value, {
|
||||
variantKeys: ['I', 'II'],
|
||||
allowedAttributeIds: new Set(['weapon-damage'])
|
||||
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')
|
||||
]));
|
||||
expect(result.errors).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('is not defined for Extra Health changed'),
|
||||
]),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -179,12 +228,18 @@ describe('conditional mechanic authoring payload', () => {
|
||||
const comparison = { pool: 'stacks', operator: 'gte' as const, value: 10 };
|
||||
const predicate = statePoolThresholdPredicate(comparison);
|
||||
expect(readStatePoolThreshold(predicate)).toEqual(comparison);
|
||||
expect(readStatePoolThreshold({
|
||||
expect(
|
||||
readStatePoolThreshold({
|
||||
kind: 'compare',
|
||||
left: { source: 'pool', pool: 'stacks', entity: { kind: 'event_target' } },
|
||||
left: {
|
||||
source: 'pool',
|
||||
pool: 'stacks',
|
||||
entity: { kind: 'event_target' },
|
||||
},
|
||||
operator: 'gt',
|
||||
right: 0
|
||||
})).toBeNull();
|
||||
right: 0,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('parses movement-state and stack-threshold authoring predicates unchanged', () => {
|
||||
@@ -193,7 +248,7 @@ describe('conditional mechanic authoring payload', () => {
|
||||
value.clauses[0].if = eventFieldComparisonPredicate({
|
||||
path: 'values.state',
|
||||
operator: 'eq',
|
||||
value: 'sprinting'
|
||||
value: 'sprinting',
|
||||
});
|
||||
value.clauses.push({
|
||||
...structuredClone(value.clauses[0]),
|
||||
@@ -202,20 +257,20 @@ describe('conditional mechanic authoring payload', () => {
|
||||
if: statePoolThresholdPredicate({
|
||||
pool: 'effect',
|
||||
operator: 'gt',
|
||||
value: 0
|
||||
})
|
||||
value: 0,
|
||||
}),
|
||||
});
|
||||
const result = parseConditionalMechanicDraft(value, {
|
||||
variantKeys: ['I', 'II'],
|
||||
allowedAttributeIds: new Set(['weapon-damage'])
|
||||
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)
|
||||
value.clauses.map((clause) => clause.if),
|
||||
);
|
||||
expect(result.value.programs.I.clauses.map((clause) => clause.if)).toEqual(
|
||||
value.clauses.map((clause) => clause.if)
|
||||
value.clauses.map((clause) => clause.if),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -225,10 +280,10 @@ describe('conditional mechanic authoring payload', () => {
|
||||
simulationReadiness: 'captured_only',
|
||||
scenarioInputs: [],
|
||||
pools: [],
|
||||
clauses: []
|
||||
clauses: [],
|
||||
};
|
||||
const result = parseConditionalMechanicDraft(partial, {
|
||||
variantKeys: ['I', 'II']
|
||||
variantKeys: ['I', 'II'],
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
@@ -243,14 +298,16 @@ describe('conditional mechanic authoring payload', () => {
|
||||
simulationReadiness: 'ready',
|
||||
scenarioInputs: [],
|
||||
pools: [],
|
||||
clauses: []
|
||||
clauses: [],
|
||||
};
|
||||
const result = parseConditionalMechanicDraft(partial, {
|
||||
variantKeys: ['I', 'II']
|
||||
variantKeys: ['I', 'II'],
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.errors).toContain('A simulation-ready mechanic requires at least one clause.');
|
||||
expect(result.errors).toContain(
|
||||
'A simulation-ready mechanic requires at least one clause.',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -260,7 +317,7 @@ describe('conditional mechanic authoring payload', () => {
|
||||
programId: 'superior-firepower',
|
||||
programName: 'Superior Firepower',
|
||||
allowedAttributeIds: new Set(['weapon-damage']),
|
||||
allowedRecipeKeys: new Set(['same_target_hits_within_window'])
|
||||
allowedRecipeKeys: new Set(['same_target_hits_within_window']),
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
@@ -271,11 +328,13 @@ describe('conditional mechanic authoring payload', () => {
|
||||
recipeKey: 'same_target_hits_within_window',
|
||||
cooldownSeconds: 2,
|
||||
weight: 1,
|
||||
outcomes: [{
|
||||
outcomes: [
|
||||
{
|
||||
id: 'weapon-damage',
|
||||
scope: 'equipped_weapon',
|
||||
value: 3
|
||||
}]
|
||||
value: 3,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result.value.programs.II.clauses[0].outcomes?.[0].value).toBe(4.2);
|
||||
});
|
||||
@@ -284,14 +343,17 @@ describe('conditional mechanic authoring payload', () => {
|
||||
const happy: ConditionalMechanicDraftV1 = {
|
||||
version: 1,
|
||||
simulationReadiness: 'requires_assumptions',
|
||||
scenarioInputs: [{
|
||||
scenarioInputs: [
|
||||
{
|
||||
key: 'restoration-events-per-minute',
|
||||
label: 'Restoration events per minute',
|
||||
kind: 'number',
|
||||
readiness: 'requires_assumption',
|
||||
min: 0
|
||||
}],
|
||||
pools: [{
|
||||
min: 0,
|
||||
},
|
||||
],
|
||||
pools: [
|
||||
{
|
||||
key: 'nearby-enemies',
|
||||
label: 'Nearby Enemies',
|
||||
stateKind: 'count',
|
||||
@@ -299,9 +361,11 @@ describe('conditional mechanic authoring payload', () => {
|
||||
initial: 0,
|
||||
min: 0,
|
||||
max: 20,
|
||||
bestCaseValue: 5
|
||||
}],
|
||||
clauses: [{
|
||||
bestCaseValue: 5,
|
||||
},
|
||||
],
|
||||
clauses: [
|
||||
{
|
||||
key: 'shot-health-exchange',
|
||||
event: 'shot_fired',
|
||||
reducer: { kind: 'none' },
|
||||
@@ -315,7 +379,7 @@ describe('conditional mechanic authoring payload', () => {
|
||||
resource: 'health',
|
||||
scope: 'self',
|
||||
basis: 'max_health_percent',
|
||||
variants: { base: { value: 2 } }
|
||||
variants: { base: { value: 2 } },
|
||||
},
|
||||
{
|
||||
key: 'bonus-damage',
|
||||
@@ -339,27 +403,32 @@ describe('conditional mechanic authoring payload', () => {
|
||||
left: {
|
||||
ref: {
|
||||
source: 'equipment',
|
||||
path: 'protocolTalentUpgrades'
|
||||
}
|
||||
path: 'protocolTalentUpgrades',
|
||||
},
|
||||
right: 5
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}]
|
||||
},
|
||||
right: 5,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = parseConditionalMechanicDraft(happy, {
|
||||
variantKeys: ['base'],
|
||||
programName: 'Happy Survival'
|
||||
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: 'resource_cost',
|
||||
resource: 'health',
|
||||
value: 2,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: 'direct_damage',
|
||||
combatTiming: 'current',
|
||||
@@ -369,13 +438,13 @@ describe('conditional mechanic authoring payload', () => {
|
||||
left: {
|
||||
ref: {
|
||||
source: 'equipment',
|
||||
path: 'protocolTalentUpgrades'
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
path: 'protocolTalentUpgrades',
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -402,12 +471,12 @@ describe('conditional mechanic authoring payload', () => {
|
||||
left: {
|
||||
ref: {
|
||||
source: 'attribute',
|
||||
attributeId: 'firepower-multiplier'
|
||||
}
|
||||
attributeId: 'firepower-multiplier',
|
||||
},
|
||||
},
|
||||
right: 100,
|
||||
},
|
||||
},
|
||||
right: 100
|
||||
}
|
||||
}
|
||||
},
|
||||
II: {
|
||||
value: 336,
|
||||
@@ -419,46 +488,50 @@ describe('conditional mechanic authoring payload', () => {
|
||||
left: {
|
||||
ref: {
|
||||
source: 'attribute',
|
||||
attributeId: 'firepower-multiplier'
|
||||
}
|
||||
attributeId: 'firepower-multiplier',
|
||||
},
|
||||
},
|
||||
right: 100,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
right: 100
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const accepted = parseConditionalMechanicDraft(referenced, {
|
||||
variantKeys: ['I', 'II'],
|
||||
allowedAttributeIds: new Set(['firepower-multiplier'])
|
||||
allowedAttributeIds: new Set(['firepower-multiplier']),
|
||||
});
|
||||
expect(accepted.ok).toBe(true);
|
||||
if (accepted.ok) {
|
||||
expect(accepted.value.programs.I.clauses[0].outcomes?.[0].value).toMatchObject({
|
||||
expect(
|
||||
accepted.value.programs.I.clauses[0].outcomes?.[0].value,
|
||||
).toMatchObject({
|
||||
right: {
|
||||
left: {
|
||||
ref: {
|
||||
source: 'attribute',
|
||||
attributeId: 'firepower-multiplier'
|
||||
}
|
||||
}
|
||||
}
|
||||
attributeId: 'firepower-multiplier',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(accepted.value.programs.II.clauses[0].outcomes?.[0]).toMatchObject({
|
||||
expect(accepted.value.programs.II.clauses[0].outcomes?.[0]).toMatchObject(
|
||||
{
|
||||
delivery: 'continuous',
|
||||
durationSeconds: 3,
|
||||
value: { left: 336 }
|
||||
});
|
||||
value: { left: 336 },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const rejected = parseConditionalMechanicDraft(referenced, {
|
||||
variantKeys: ['I', 'II'],
|
||||
allowedAttributeIds: new Set(['weapon-damage'])
|
||||
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".'
|
||||
'Conditional mechanic clauses item 1 outcomes item 1 variants I expression right left ref references unknown Attribute "firepower-multiplier".',
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -468,26 +541,32 @@ describe('conditional mechanic authoring payload', () => {
|
||||
version: 1,
|
||||
simulationReadiness: 'ready',
|
||||
scenarioInputs: [],
|
||||
pools: [{
|
||||
pools: [
|
||||
{
|
||||
key: 'marked-target',
|
||||
label: 'Marked Target',
|
||||
stateKind: 'target_mark',
|
||||
scope: 'target',
|
||||
initial: 0,
|
||||
min: 0,
|
||||
max: 1
|
||||
}],
|
||||
clauses: [{
|
||||
max: 1,
|
||||
},
|
||||
],
|
||||
clauses: [
|
||||
{
|
||||
key: 'detonate-mark',
|
||||
event: 'enemy_killed',
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [{
|
||||
mutations: [
|
||||
{
|
||||
kind: 'set',
|
||||
pool: 'marked-target',
|
||||
entity: { kind: 'event_target' },
|
||||
value: 1
|
||||
}],
|
||||
outcomes: [{
|
||||
value: 1,
|
||||
},
|
||||
],
|
||||
outcomes: [
|
||||
{
|
||||
key: 'target-damage',
|
||||
label: 'Target Damage',
|
||||
phase: 'on_exit',
|
||||
@@ -507,20 +586,22 @@ describe('conditional mechanic authoring payload', () => {
|
||||
left: {
|
||||
ref: {
|
||||
source: 'attribute',
|
||||
attributeId: 'firepower-multiplier'
|
||||
}
|
||||
attributeId: 'firepower-multiplier',
|
||||
},
|
||||
right: 100
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}]
|
||||
}]
|
||||
},
|
||||
right: 100,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = parseConditionalMechanicDraft(targeted, {
|
||||
variantKeys: ['base'],
|
||||
allowedAttributeIds: new Set(['firepower-multiplier'])
|
||||
allowedAttributeIds: new Set(['firepower-multiplier']),
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
@@ -528,14 +609,18 @@ describe('conditional mechanic authoring payload', () => {
|
||||
expect(result.value.draft).toMatchObject(targeted);
|
||||
expect(result.value.programs.base).toMatchObject({
|
||||
pools: [{ key: 'marked-target', scope: 'target' }],
|
||||
clauses: [{
|
||||
mutations: [{
|
||||
clauses: [
|
||||
{
|
||||
mutations: [
|
||||
{
|
||||
kind: 'set',
|
||||
pool: 'marked-target',
|
||||
entity: { kind: 'event_target' },
|
||||
value: 1
|
||||
}],
|
||||
outcomes: [{
|
||||
value: 1,
|
||||
},
|
||||
],
|
||||
outcomes: [
|
||||
{
|
||||
phase: 'on_exit',
|
||||
kind: 'direct_damage',
|
||||
statePool: 'marked-target',
|
||||
@@ -545,28 +630,31 @@ describe('conditional mechanic authoring payload', () => {
|
||||
left: {
|
||||
ref: {
|
||||
source: 'attribute',
|
||||
attributeId: 'firepower-multiplier'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}]
|
||||
}]
|
||||
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<string, unknown>).cadence =
|
||||
'continuous';
|
||||
(
|
||||
unsupported.clauses[0].outcomes[0] as unknown as Record<string, unknown>
|
||||
).cadence = 'continuous';
|
||||
const result = parseConditionalMechanicDraft(unsupported, {
|
||||
variantKeys: ['I', 'II'],
|
||||
allowedAttributeIds: new Set(['weapon-damage'])
|
||||
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".'
|
||||
'Clause 1 outcome 1 contains unsupported field "cadence".',
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -575,21 +663,25 @@ describe('conditional mechanic authoring payload', () => {
|
||||
const invalid = draft();
|
||||
invalid.clauses[0].outcomes[0].variants = {
|
||||
I: { value: 3 },
|
||||
legacy: { value: 99 }
|
||||
legacy: { value: 99 },
|
||||
};
|
||||
const result = parseConditionalMechanicDraft(invalid, { variantKeys: ['I', 'II'] });
|
||||
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.'
|
||||
'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({
|
||||
expect(() =>
|
||||
parseConditionalMechanicDraft('{not-json', { variantKeys: ['base'] }),
|
||||
).not.toThrow();
|
||||
expect(
|
||||
parseConditionalMechanicDraft('{not-json', { variantKeys: ['base'] }),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
errors: ['The conditional mechanic contains malformed JSON.']
|
||||
errors: ['The conditional mechanic contains malformed JSON.'],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -597,13 +689,17 @@ describe('conditional mechanic authoring payload', () => {
|
||||
const invalid = draft();
|
||||
invalid.clauses[0].if = {
|
||||
kind: 'compare',
|
||||
left: { source: 'combat', path: 'currentHealthPercent', timing: 'pre_event' },
|
||||
left: {
|
||||
source: 'combat',
|
||||
path: 'currentHealthPercent',
|
||||
timing: 'pre_event',
|
||||
},
|
||||
operator: 'lt',
|
||||
right: 50
|
||||
right: 50,
|
||||
};
|
||||
const result = parseConditionalMechanicDraft(invalid, {
|
||||
variantKeys: ['I', 'II'],
|
||||
allowedAttributeIds: new Set(['weapon-damage'])
|
||||
allowedAttributeIds: new Set(['weapon-damage']),
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
@@ -614,28 +710,28 @@ describe('conditional mechanic authoring payload', () => {
|
||||
valid.clauses[0].outcomes[0].targeting = {
|
||||
kind: 'within_radius',
|
||||
population: 'allies',
|
||||
radiusMeters: 40
|
||||
radiusMeters: 40,
|
||||
};
|
||||
const parsed = parseConditionalMechanicDraft(valid, {
|
||||
variantKeys: ['I', 'II'],
|
||||
allowedAttributeIds: new Set(['weapon-damage'])
|
||||
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].targeting,
|
||||
);
|
||||
}
|
||||
|
||||
valid.clauses[0].outcomes[0].scope = 'self';
|
||||
const incompatible = parseConditionalMechanicDraft(valid, {
|
||||
variantKeys: ['I', 'II'],
|
||||
allowedAttributeIds: new Set(['weapon-damage'])
|
||||
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.'
|
||||
'Clause 1 outcome 1 ally radius targeting requires allies or self-and-allies scope.',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,11 +4,11 @@ import {
|
||||
type ConditionalEffectClauseRow,
|
||||
type ConditionalEffectOutcomeRow,
|
||||
type ConditionalEffectRuleRow,
|
||||
type ConditionalEffectStatePoolRow
|
||||
type ConditionalEffectStatePoolRow,
|
||||
} from './mechanic-persistence.js';
|
||||
|
||||
const rule = (
|
||||
overrides: Partial<ConditionalEffectRuleRow> = {}
|
||||
overrides: Partial<ConditionalEffectRuleRow> = {},
|
||||
): ConditionalEffectRuleRow => ({
|
||||
id: 'rule-1',
|
||||
source_type: 'talent',
|
||||
@@ -17,11 +17,11 @@ const rule = (
|
||||
script_version: 1,
|
||||
simulation_readiness: 'ready',
|
||||
scenario_inputs_json: '[]',
|
||||
...overrides
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const pool = (
|
||||
overrides: Partial<ConditionalEffectStatePoolRow> = {}
|
||||
overrides: Partial<ConditionalEffectStatePoolRow> = {},
|
||||
): ConditionalEffectStatePoolRow => ({
|
||||
id: 'pool-1',
|
||||
rule_id: 'rule-1',
|
||||
@@ -38,11 +38,11 @@ const pool = (
|
||||
refresh_policy: 'refresh',
|
||||
parameters_json: '{}',
|
||||
sort_order: 1,
|
||||
...overrides
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const clause = (
|
||||
overrides: Partial<ConditionalEffectClauseRow> = {}
|
||||
overrides: Partial<ConditionalEffectClauseRow> = {},
|
||||
): ConditionalEffectClauseRow => ({
|
||||
id: 'clause-1',
|
||||
rule_id: 'rule-1',
|
||||
@@ -59,11 +59,11 @@ const clause = (
|
||||
output_branch: 'active',
|
||||
sort_order: 1,
|
||||
active: 1,
|
||||
...overrides
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const outcome = (
|
||||
overrides: Partial<ConditionalEffectOutcomeRow> = {}
|
||||
overrides: Partial<ConditionalEffectOutcomeRow> = {},
|
||||
): ConditionalEffectOutcomeRow => ({
|
||||
id: 'outcome-1',
|
||||
rule_id: 'rule-1',
|
||||
@@ -87,55 +87,64 @@ const outcome = (
|
||||
max_state_value: null,
|
||||
description_slot: 1,
|
||||
sort_order: 1,
|
||||
...overrides
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('conditional-effect persistence adapter', () => {
|
||||
it('adapts a migrated simple same-target rule without losing its typed mechanics', () => {
|
||||
const result = adaptConditionalEffectProgram({
|
||||
const result = adaptConditionalEffectProgram(
|
||||
{
|
||||
rule: rule(),
|
||||
statePools: [pool()],
|
||||
clauses: [clause()],
|
||||
outcomes: [outcome()],
|
||||
triggers: [{
|
||||
triggers: [
|
||||
{
|
||||
id: 'trigger-1',
|
||||
action_key: 'same_target_hits_within_window',
|
||||
name: 'Same-target hits'
|
||||
}]
|
||||
}, { name: 'Superior Firepower', variantKey: 'I' });
|
||||
name: 'Same-target hits',
|
||||
},
|
||||
],
|
||||
},
|
||||
{ name: 'Superior Firepower', variantKey: 'I' },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'READY',
|
||||
simulationReadiness: 'ready',
|
||||
errors: []
|
||||
errors: [],
|
||||
});
|
||||
expect(result.program).toMatchObject({
|
||||
id: 'rule-1',
|
||||
name: 'Superior Firepower',
|
||||
pools: [{
|
||||
pools: [
|
||||
{
|
||||
key: 'effect',
|
||||
scope: 'agent',
|
||||
initial: 0,
|
||||
max: 10,
|
||||
bestCaseValue: 10,
|
||||
expiresAfterSeconds: 10
|
||||
}],
|
||||
clauses: [{
|
||||
expiresAfterSeconds: 10,
|
||||
},
|
||||
],
|
||||
clauses: [
|
||||
{
|
||||
id: 'clause-1',
|
||||
when: 'weapon_hit',
|
||||
if: {
|
||||
kind: 'exists',
|
||||
value: { source: 'event', path: 'targetId' }
|
||||
value: { source: 'event', path: 'targetId' },
|
||||
},
|
||||
reducer: {
|
||||
kind: 'sliding_count',
|
||||
threshold: 9,
|
||||
windowSeconds: 3,
|
||||
reset: 'zero',
|
||||
groupBy: { source: 'event', path: 'targetId' }
|
||||
groupBy: { source: 'event', path: 'targetId' },
|
||||
},
|
||||
mutations: [{ kind: 'add', pool: 'effect', value: 1 }],
|
||||
outcomes: [{
|
||||
outcomes: [
|
||||
{
|
||||
id: 'outcome-1',
|
||||
phase: 'while_active',
|
||||
kind: 'attribute_modifier',
|
||||
@@ -145,29 +154,40 @@ describe('conditional-effect persistence adapter', () => {
|
||||
basis: 'flat',
|
||||
statePool: 'effect',
|
||||
perStack: true,
|
||||
minStacks: 1
|
||||
}]
|
||||
}]
|
||||
minStacks: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result.program?.clauses[0].outcomes?.[0]).not.toHaveProperty('delivery');
|
||||
expect(result.program?.clauses[0].outcomes?.[0]).not.toHaveProperty('durationSeconds');
|
||||
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({
|
||||
const result = adaptConditionalEffectProgram(
|
||||
{
|
||||
rule: rule(),
|
||||
statePools: [pool()],
|
||||
clauses: [clause({ trigger_id: null, event_kind: 'enemy_killed' })],
|
||||
outcomes: [outcome({
|
||||
outcomes: [
|
||||
outcome({
|
||||
effect_scope: 'allies',
|
||||
targeting_json: JSON.stringify({
|
||||
kind: 'within_radius',
|
||||
population: 'allies',
|
||||
radiusMeters: 40
|
||||
})
|
||||
})],
|
||||
triggers: []
|
||||
}, { name: 'Slaughter Healing', variantKey: 'I' });
|
||||
radiusMeters: 40,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
triggers: [],
|
||||
},
|
||||
{ name: 'Slaughter Healing', variantKey: 'I' },
|
||||
);
|
||||
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.program?.clauses[0].outcomes?.[0]).toMatchObject({
|
||||
@@ -175,23 +195,27 @@ describe('conditional-effect persistence adapter', () => {
|
||||
targeting: {
|
||||
kind: 'within_radius',
|
||||
population: 'allies',
|
||||
radiusMeters: 40
|
||||
}
|
||||
radiusMeters: 40,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('loads continuous restoration delivery metadata', () => {
|
||||
const result = adaptConditionalEffectProgram({
|
||||
const result = adaptConditionalEffectProgram(
|
||||
{
|
||||
rule: rule(),
|
||||
statePools: [],
|
||||
clauses: [clause({
|
||||
clauses: [
|
||||
clause({
|
||||
trigger_id: null,
|
||||
event_kind: 'enemy_killed',
|
||||
parameters_json: '{}',
|
||||
state_pool_id: null,
|
||||
mutations_json: '[]'
|
||||
})],
|
||||
outcomes: [outcome({
|
||||
mutations_json: '[]',
|
||||
}),
|
||||
],
|
||||
outcomes: [
|
||||
outcome({
|
||||
state_pool_id: null,
|
||||
effect_key: 'predatory-restoration',
|
||||
application_kind: 'restore_health',
|
||||
@@ -203,70 +227,90 @@ describe('conditional-effect persistence adapter', () => {
|
||||
resource_kind: 'health',
|
||||
delivery_mode: 'continuous',
|
||||
delivery_duration_seconds: 3,
|
||||
min_state_value: null
|
||||
})],
|
||||
triggers: []
|
||||
}, { name: 'Predatory Aura', variantKey: 'I' });
|
||||
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
|
||||
durationSeconds: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects incomplete continuous restoration metadata', () => {
|
||||
const result = adaptConditionalEffectProgram({
|
||||
const result = adaptConditionalEffectProgram(
|
||||
{
|
||||
rule: rule(),
|
||||
statePools: [],
|
||||
clauses: [clause({
|
||||
clauses: [
|
||||
clause({
|
||||
trigger_id: null,
|
||||
event_kind: 'enemy_killed',
|
||||
parameters_json: '{}',
|
||||
state_pool_id: null,
|
||||
mutations_json: '[]'
|
||||
})],
|
||||
outcomes: [outcome({
|
||||
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' });
|
||||
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.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({
|
||||
const result = adaptConditionalEffectProgram(
|
||||
{
|
||||
rule: rule(),
|
||||
statePools: [pool({
|
||||
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({
|
||||
best_case_value: 1,
|
||||
}),
|
||||
],
|
||||
clauses: [
|
||||
clause({
|
||||
trigger_id: null,
|
||||
event_kind: 'enemy_killed',
|
||||
parameters_json: '{}',
|
||||
mutations_json: JSON.stringify([{
|
||||
mutations_json: JSON.stringify([
|
||||
{
|
||||
kind: 'set',
|
||||
pool: 'marked-target',
|
||||
entity: { kind: 'event_target' },
|
||||
value: 1
|
||||
}])
|
||||
})],
|
||||
outcomes: [outcome({
|
||||
value: 1,
|
||||
},
|
||||
]),
|
||||
}),
|
||||
],
|
||||
outcomes: [
|
||||
outcome({
|
||||
effect_key: 'marked-target-detonation',
|
||||
application_kind: 'direct_damage',
|
||||
effect_scope: 'target',
|
||||
@@ -280,33 +324,39 @@ describe('conditional-effect persistence adapter', () => {
|
||||
left: {
|
||||
ref: {
|
||||
source: 'attribute',
|
||||
attributeId: firepowerMultiplierId
|
||||
}
|
||||
attributeId: firepowerMultiplierId,
|
||||
},
|
||||
},
|
||||
right: 100,
|
||||
},
|
||||
right: 100
|
||||
}
|
||||
}),
|
||||
value_scaling: 'per_state_unit',
|
||||
damage_basis: 'flat',
|
||||
application_phase: 'on_exit',
|
||||
min_state_value: null
|
||||
})],
|
||||
triggers: []
|
||||
}, { name: 'Target Detonation', variantKey: 'I' });
|
||||
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'
|
||||
scope: 'target',
|
||||
});
|
||||
expect(result.program?.clauses[0]).toMatchObject({
|
||||
mutations: [{
|
||||
mutations: [
|
||||
{
|
||||
kind: 'set',
|
||||
pool: 'marked-target',
|
||||
entity: { kind: 'event_target' },
|
||||
value: 1
|
||||
}],
|
||||
outcomes: [{
|
||||
value: 1,
|
||||
},
|
||||
],
|
||||
outcomes: [
|
||||
{
|
||||
phase: 'on_exit',
|
||||
kind: 'direct_damage',
|
||||
scope: 'target',
|
||||
@@ -320,34 +370,46 @@ describe('conditional-effect persistence adapter', () => {
|
||||
left: {
|
||||
ref: {
|
||||
source: 'attribute',
|
||||
attributeId: firepowerMultiplierId
|
||||
}
|
||||
attributeId: firepowerMultiplierId,
|
||||
},
|
||||
right: 100
|
||||
}
|
||||
}
|
||||
}]
|
||||
},
|
||||
right: 100,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects malformed mutation entity selectors from persisted JSON', () => {
|
||||
const result = adaptConditionalEffectProgram({
|
||||
const result = adaptConditionalEffectProgram(
|
||||
{
|
||||
rule: rule(),
|
||||
statePools: [pool({ state_scope: 'target' })],
|
||||
clauses: [clause({
|
||||
mutations_json: JSON.stringify([{
|
||||
clauses: [
|
||||
clause({
|
||||
mutations_json: JSON.stringify([
|
||||
{
|
||||
kind: 'set',
|
||||
pool: 'effect',
|
||||
entity: { kind: 'event_field', path: '' },
|
||||
value: 1
|
||||
}])
|
||||
})],
|
||||
value: 1,
|
||||
},
|
||||
]),
|
||||
}),
|
||||
],
|
||||
outcomes: [outcome()],
|
||||
triggers: [{ id: 'trigger-1', action_key: 'same_target_hits_within_window' }]
|
||||
}, { variantKey: 'I' });
|
||||
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.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', () => {
|
||||
@@ -357,31 +419,36 @@ describe('conditional-effect persistence adapter', () => {
|
||||
right: {
|
||||
op: 'divide',
|
||||
left: { ref: { source: 'equipment', path: 'protocolTalentUpgrades' } },
|
||||
right: 5
|
||||
}
|
||||
right: 5,
|
||||
},
|
||||
};
|
||||
const result = adaptConditionalEffectProgram({
|
||||
const result = adaptConditionalEffectProgram(
|
||||
{
|
||||
rule: rule({
|
||||
source_type: 'os_protocol_talent',
|
||||
source_id: 'happy-survival',
|
||||
simulation_readiness: 'requires_assumptions',
|
||||
scenario_inputs_json: JSON.stringify([{
|
||||
scenario_inputs_json: JSON.stringify([
|
||||
{
|
||||
key: 'restorationIntervalSeconds',
|
||||
label: 'Restoration interval',
|
||||
kind: 'number',
|
||||
readiness: 'requires_assumption',
|
||||
description: 'Enter the verified talent cadence.',
|
||||
min: 0.01
|
||||
}])
|
||||
min: 0.01,
|
||||
},
|
||||
]),
|
||||
}),
|
||||
statePools: [pool({
|
||||
statePools: [
|
||||
pool({
|
||||
state_key: 'nearby-enemies',
|
||||
label: 'Nearby Enemies',
|
||||
state_kind: 'count',
|
||||
maximum_value: 20,
|
||||
best_case_value: 5,
|
||||
duration_seconds: null
|
||||
})],
|
||||
duration_seconds: null,
|
||||
}),
|
||||
],
|
||||
clauses: [
|
||||
clause({
|
||||
id: 'sample-nearby',
|
||||
@@ -392,14 +459,18 @@ describe('conditional-effect persistence adapter', () => {
|
||||
parameters_json: '{}',
|
||||
reducer_json: JSON.stringify({
|
||||
kind: 'derived_scalar',
|
||||
value: { ref: { source: 'event', path: 'values.enemiesWithinRadius' } }
|
||||
value: {
|
||||
ref: { source: 'event', path: 'values.enemiesWithinRadius' },
|
||||
},
|
||||
}),
|
||||
mutations_json: JSON.stringify([{
|
||||
mutations_json: JSON.stringify([
|
||||
{
|
||||
kind: 'derive',
|
||||
pool: 'nearby-enemies',
|
||||
value: { ref: { source: 'reducer' } }
|
||||
}]),
|
||||
sort_order: 1
|
||||
value: { ref: { source: 'reducer' } },
|
||||
},
|
||||
]),
|
||||
sort_order: 1,
|
||||
}),
|
||||
clause({
|
||||
id: 'restore-nearby',
|
||||
@@ -409,8 +480,10 @@ describe('conditional-effect persistence adapter', () => {
|
||||
event_kind: 'tick',
|
||||
weight_expression_json: JSON.stringify({
|
||||
op: 'divide',
|
||||
left: { ref: { source: 'scenario', key: 'restorationIntervalSeconds' } },
|
||||
right: 60
|
||||
left: {
|
||||
ref: { source: 'scenario', key: 'restorationIntervalSeconds' },
|
||||
},
|
||||
right: 60,
|
||||
}),
|
||||
parameters_json: '{}',
|
||||
reducer_json: JSON.stringify({
|
||||
@@ -418,13 +491,13 @@ describe('conditional-effect persistence adapter', () => {
|
||||
intervalSeconds: {
|
||||
ref: {
|
||||
source: 'scenario',
|
||||
key: 'restorationIntervalSeconds'
|
||||
}
|
||||
}
|
||||
key: 'restorationIntervalSeconds',
|
||||
},
|
||||
},
|
||||
}),
|
||||
mutations_json: JSON.stringify([{ kind: 'none' }]),
|
||||
state_pool_id: null,
|
||||
sort_order: 2
|
||||
sort_order: 2,
|
||||
}),
|
||||
clause({
|
||||
id: 'shot-health-exchange',
|
||||
@@ -436,8 +509,8 @@ describe('conditional-effect persistence adapter', () => {
|
||||
parameters_json: '{}',
|
||||
mutations_json: JSON.stringify([{ kind: 'none' }]),
|
||||
state_pool_id: null,
|
||||
sort_order: 3
|
||||
})
|
||||
sort_order: 3,
|
||||
}),
|
||||
],
|
||||
outcomes: [
|
||||
outcome({
|
||||
@@ -457,19 +530,19 @@ describe('conditional-effect persistence adapter', () => {
|
||||
value: {
|
||||
op: 'multiply',
|
||||
left: { ref: { source: 'pool', pool: 'nearby-enemies' } },
|
||||
right: 0.5
|
||||
right: 0.5,
|
||||
},
|
||||
min: 0,
|
||||
max: 2.5
|
||||
max: 2.5,
|
||||
},
|
||||
right: upgradeScale
|
||||
right: upgradeScale,
|
||||
}),
|
||||
value_basis: 'max_health_percent',
|
||||
resource_kind: 'health',
|
||||
value_scaling: 'flat',
|
||||
application_phase: 'after_event',
|
||||
min_state_value: null,
|
||||
sort_order: 1
|
||||
sort_order: 1,
|
||||
}),
|
||||
outcome({
|
||||
id: 'health-cost',
|
||||
@@ -486,7 +559,7 @@ describe('conditional-effect persistence adapter', () => {
|
||||
value_scaling: 'flat',
|
||||
application_phase: 'on_event',
|
||||
min_state_value: null,
|
||||
sort_order: 2
|
||||
sort_order: 2,
|
||||
}),
|
||||
outcome({
|
||||
id: 'bonus-damage',
|
||||
@@ -501,7 +574,7 @@ describe('conditional-effect persistence adapter', () => {
|
||||
value_expression_json: JSON.stringify({
|
||||
op: 'multiply',
|
||||
left: 0.6,
|
||||
right: upgradeScale
|
||||
right: upgradeScale,
|
||||
}),
|
||||
value_basis: 'current_health_percent',
|
||||
combat_timing: 'current',
|
||||
@@ -509,27 +582,29 @@ describe('conditional-effect persistence adapter', () => {
|
||||
damage_basis: 'flat',
|
||||
application_phase: 'on_event',
|
||||
min_state_value: null,
|
||||
sort_order: 3
|
||||
})
|
||||
sort_order: 3,
|
||||
}),
|
||||
],
|
||||
triggers: []
|
||||
}, { name: 'Happy Survival', variantKey: 'base' });
|
||||
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'
|
||||
readiness: 'requires_assumption',
|
||||
});
|
||||
expect(result.program?.clauses.map(({ when }) => when)).toEqual([
|
||||
'proximity_snapshot',
|
||||
'tick',
|
||||
'shot_fired'
|
||||
'shot_fired',
|
||||
]);
|
||||
expect(result.program?.clauses[1].weight).toEqual({
|
||||
op: 'divide',
|
||||
left: { ref: { source: 'scenario', key: 'restorationIntervalSeconds' } },
|
||||
right: 60
|
||||
right: 60,
|
||||
});
|
||||
expect(result.program?.clauses[2].recipeKey).toBe('happy-survival-shot');
|
||||
expect(result.program?.clauses[1].reducer).toEqual({
|
||||
@@ -537,9 +612,9 @@ describe('conditional-effect persistence adapter', () => {
|
||||
intervalSeconds: {
|
||||
ref: {
|
||||
source: 'scenario',
|
||||
key: 'restorationIntervalSeconds'
|
||||
}
|
||||
}
|
||||
key: 'restorationIntervalSeconds',
|
||||
},
|
||||
},
|
||||
});
|
||||
const restoration = result.program?.clauses[1].outcomes?.[0];
|
||||
const shotOutcomes = result.program?.clauses[2].outcomes;
|
||||
@@ -555,12 +630,12 @@ describe('conditional-effect persistence adapter', () => {
|
||||
left: {
|
||||
ref: {
|
||||
source: 'equipment',
|
||||
path: 'protocolTalentUpgrades'
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
path: 'protocolTalentUpgrades',
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
expect(shotOutcomes).toEqual([
|
||||
expect.objectContaining({
|
||||
@@ -569,7 +644,7 @@ describe('conditional-effect persistence adapter', () => {
|
||||
resource: 'health',
|
||||
phase: 'on_event',
|
||||
basis: 'max_health_percent',
|
||||
value: 2
|
||||
value: 2,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'bonus-damage',
|
||||
@@ -584,61 +659,84 @@ describe('conditional-effect persistence adapter', () => {
|
||||
left: {
|
||||
ref: {
|
||||
source: 'equipment',
|
||||
path: 'protocolTalentUpgrades'
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
path: 'protocolTalentUpgrades',
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns INVALID instead of throwing when persisted JSON is malformed', () => {
|
||||
expect(() => adaptConditionalEffectProgram({
|
||||
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();
|
||||
triggers: [
|
||||
{ id: 'trigger-1', action_key: 'same_target_hits_within_window' },
|
||||
],
|
||||
},
|
||||
{ variantKey: 'I' },
|
||||
),
|
||||
).not.toThrow();
|
||||
|
||||
const result = adaptConditionalEffectProgram({
|
||||
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' });
|
||||
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([
|
||||
expect(result.errors).toEqual(
|
||||
expect.arrayContaining([
|
||||
'Rule scenario inputs contains malformed JSON.',
|
||||
'Clause 1 mutations 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' },
|
||||
left: {
|
||||
source: 'combat',
|
||||
path: 'currentHealthPercent',
|
||||
timing: 'current',
|
||||
},
|
||||
operator: 'lt',
|
||||
right: 25
|
||||
right: 25,
|
||||
};
|
||||
const result = adaptConditionalEffectProgram({
|
||||
const result = adaptConditionalEffectProgram(
|
||||
{
|
||||
rule: rule(),
|
||||
statePools: [],
|
||||
clauses: [clause({
|
||||
clauses: [
|
||||
clause({
|
||||
trigger_id: null,
|
||||
event_kind: 'tick',
|
||||
parameters_json: '{}',
|
||||
conditions_json: JSON.stringify(condition),
|
||||
state_pool_id: null,
|
||||
mutations_json: '[]'
|
||||
})],
|
||||
mutations_json: '[]',
|
||||
}),
|
||||
],
|
||||
outcomes: [],
|
||||
triggers: []
|
||||
}, { name: 'Spirited', variantKey: 'I' });
|
||||
triggers: [],
|
||||
},
|
||||
{ name: 'Spirited', variantKey: 'I' },
|
||||
);
|
||||
|
||||
expect(result.status).toBe('READY');
|
||||
expect(result.program?.clauses[0].if).toEqual(condition);
|
||||
@@ -649,27 +747,32 @@ describe('conditional-effect persistence adapter', () => {
|
||||
kind: 'compare',
|
||||
left: { source: 'event', path: 'values.hasExtraHealth' },
|
||||
operator: 'eq',
|
||||
right: true
|
||||
right: true,
|
||||
};
|
||||
const result = adaptConditionalEffectProgram({
|
||||
const result = adaptConditionalEffectProgram(
|
||||
{
|
||||
rule: rule(),
|
||||
statePools: [],
|
||||
clauses: [clause({
|
||||
clauses: [
|
||||
clause({
|
||||
trigger_id: null,
|
||||
event_kind: 'extra_health_changed',
|
||||
parameters_json: '{}',
|
||||
conditions_json: JSON.stringify(condition),
|
||||
state_pool_id: null,
|
||||
mutations_json: '[]'
|
||||
})],
|
||||
mutations_json: '[]',
|
||||
}),
|
||||
],
|
||||
outcomes: [],
|
||||
triggers: []
|
||||
}, { name: 'Extra Health State', variantKey: 'I' });
|
||||
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
|
||||
if: condition,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
type PoolDefinition,
|
||||
type Predicate,
|
||||
type Reducer,
|
||||
type ScenarioInputDefinition
|
||||
type ScenarioInputDefinition,
|
||||
} from './mechanic.js';
|
||||
|
||||
type DatabaseValue = string | number | bigint | boolean | null | undefined;
|
||||
@@ -116,14 +116,12 @@ export type MechanicPersistenceOptions = {
|
||||
};
|
||||
|
||||
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;
|
||||
simulationReadiness:
|
||||
'ready' | 'requires_assumptions' | 'captured_only' | null;
|
||||
program: MechanicProgram | null;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
@@ -136,21 +134,32 @@ const object = (value: unknown): value is Record<string, unknown> =>
|
||||
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() : '';
|
||||
const text = (value: DatabaseValue) =>
|
||||
typeof value === 'string' ? value.trim() : '';
|
||||
const nullableText = (value: DatabaseValue) => {
|
||||
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;
|
||||
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';
|
||||
|
||||
function parseJson(value: DatabaseValue, path: string, errors: string[]): ParsedJson {
|
||||
function parseJson(
|
||||
value: DatabaseValue,
|
||||
path: string,
|
||||
errors: string[],
|
||||
): ParsedJson {
|
||||
if (typeof value !== 'string') {
|
||||
errors.push(`${path} must contain JSON text.`);
|
||||
return { ok: false };
|
||||
@@ -177,12 +186,18 @@ function requiredNumber(value: DatabaseValue, path: string, errors: string[]) {
|
||||
|
||||
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') {
|
||||
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());
|
||||
return (
|
||||
value.kind === 'event_field' &&
|
||||
typeof value.path === 'string' &&
|
||||
Boolean(value.path.trim())
|
||||
);
|
||||
}
|
||||
|
||||
function isValueReference(value: unknown) {
|
||||
@@ -191,22 +206,30 @@ function isValueReference(value: unknown) {
|
||||
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');
|
||||
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());
|
||||
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));
|
||||
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 {
|
||||
@@ -215,27 +238,38 @@ function isNumericExpression(value: unknown): value is NumericExpression {
|
||||
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 (
|
||||
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);
|
||||
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 === '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 (
|
||||
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;
|
||||
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 {
|
||||
@@ -244,15 +278,19 @@ function isReducer(value: unknown): value is Reducer {
|
||||
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';
|
||||
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 === '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);
|
||||
if (value.kind === 'periodic')
|
||||
return isNumericExpression(value.intervalSeconds);
|
||||
return value.kind === 'derived_scalar' && isNumericExpression(value.value);
|
||||
}
|
||||
|
||||
@@ -260,24 +298,32 @@ 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));
|
||||
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 (
|
||||
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));
|
||||
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[]
|
||||
errors: string[],
|
||||
): ScenarioInputDefinition[] {
|
||||
const parsed = parseJson(raw, 'Rule scenario inputs', errors);
|
||||
if (!parsed.ok) return [];
|
||||
@@ -293,9 +339,9 @@ function parseScenarioInputs(
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
typeof candidate.key !== 'string'
|
||||
|| typeof candidate.label !== 'string'
|
||||
|| !['toggle', 'slider', 'number'].includes(String(candidate.kind))
|
||||
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;
|
||||
@@ -315,18 +361,26 @@ function legacyActionMapping(
|
||||
action: string,
|
||||
params: Record<string, unknown>,
|
||||
path: string,
|
||||
errors: string[]
|
||||
errors: string[],
|
||||
): LegacyMapping | null {
|
||||
const eventValue = (field: string) => ({ source: 'event' as const, path: `values.${field}` });
|
||||
const eventValue = (field: string) => ({
|
||||
source: 'event' as const,
|
||||
path: `values.${field}`,
|
||||
});
|
||||
const targetExists: Predicate = {
|
||||
kind: 'exists',
|
||||
value: { source: 'event', path: 'targetId' }
|
||||
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) {
|
||||
if (
|
||||
!Number.isInteger(hits) ||
|
||||
Number(hits) <= 0 ||
|
||||
!finite(window) ||
|
||||
window <= 0
|
||||
) {
|
||||
errors.push(`${path} has invalid same-target hit parameters.`);
|
||||
return null;
|
||||
}
|
||||
@@ -338,8 +392,8 @@ function legacyActionMapping(
|
||||
threshold: Number(hits),
|
||||
windowSeconds: window,
|
||||
reset: 'zero',
|
||||
groupBy: { source: 'event', path: 'targetId' }
|
||||
}
|
||||
groupBy: { source: 'event', path: 'targetId' },
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'headshot':
|
||||
@@ -349,8 +403,8 @@ function legacyActionMapping(
|
||||
kind: 'compare',
|
||||
left: eventValue('headshot'),
|
||||
operator: 'eq',
|
||||
right: true
|
||||
}
|
||||
right: true,
|
||||
},
|
||||
};
|
||||
case 'headshot_kill':
|
||||
return {
|
||||
@@ -359,8 +413,8 @@ function legacyActionMapping(
|
||||
kind: 'compare',
|
||||
left: eventValue('headshot'),
|
||||
operator: 'eq',
|
||||
right: true
|
||||
}
|
||||
right: true,
|
||||
},
|
||||
};
|
||||
case 'weapon_kill':
|
||||
return { when: 'enemy_killed' };
|
||||
@@ -380,10 +434,10 @@ function legacyActionMapping(
|
||||
kind: 'compare',
|
||||
left: eventValue('distanceMeters'),
|
||||
operator: 'lte',
|
||||
right: meters
|
||||
}
|
||||
]
|
||||
}
|
||||
right: meters,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'damage_taken':
|
||||
@@ -394,13 +448,16 @@ function legacyActionMapping(
|
||||
return { when: 'reload_started' };
|
||||
default:
|
||||
errors.push(
|
||||
`${path} uses legacy action "${action}", which cannot be represented exactly by the mechanic runtime.`
|
||||
`${path} uses legacy action "${action}", which cannot be represented exactly by the mechanic runtime.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function combinePredicates(left: Predicate | undefined, right: Predicate | undefined) {
|
||||
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] };
|
||||
@@ -413,30 +470,41 @@ function resolveClauseMapping(
|
||||
explicitPredicate: Predicate,
|
||||
explicitReducer: Reducer,
|
||||
path: string,
|
||||
errors: string[]
|
||||
errors: string[],
|
||||
) {
|
||||
const directEvent = nullableText(eventKind)
|
||||
?? nullableText(trigger?.event_kind)
|
||||
?? (eventKinds.has(text(trigger?.action_key)) ? text(trigger?.action_key) : null);
|
||||
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
|
||||
reducer: explicitReducer,
|
||||
};
|
||||
}
|
||||
if (!trigger) {
|
||||
errors.push(`${path} requires a direct event or known Action Family recipe.`);
|
||||
errors.push(
|
||||
`${path} requires a direct event or known Action Family recipe.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const legacy = legacyActionMapping(text(trigger.action_key), parameters, path, errors);
|
||||
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
|
||||
reducer:
|
||||
explicitReducer.kind === 'none'
|
||||
? (legacy.reducer ?? explicitReducer)
|
||||
: explicitReducer,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -445,7 +513,7 @@ function parsePool(
|
||||
ruleId: string,
|
||||
index: number,
|
||||
selectedVariant: string,
|
||||
errors: string[]
|
||||
errors: string[],
|
||||
): { id: string; definition: PoolDefinition } | null {
|
||||
const path = `State pool ${index + 1}`;
|
||||
if (text(row.rule_id) !== ruleId) {
|
||||
@@ -456,21 +524,41 @@ function parsePool(
|
||||
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);
|
||||
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 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)
|
||||
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;
|
||||
const variantDuration = durationVariants
|
||||
? number(durationVariants[selectedVariant] as DatabaseValue)
|
||||
: null;
|
||||
if (durationVariants && variantDuration === null) {
|
||||
errors.push(`${path} has no valid expiration duration for variant "${selectedVariant}".`);
|
||||
errors.push(
|
||||
`${path} has no valid expiration duration for variant "${selectedVariant}".`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
id,
|
||||
@@ -484,8 +572,10 @@ function parsePool(
|
||||
...(bestCase === null ? {} : { bestCaseValue: bestCase }),
|
||||
...(variantDuration !== null
|
||||
? { expiresAfterSeconds: variantDuration }
|
||||
: duration === null ? {} : { expiresAfterSeconds: duration })
|
||||
}
|
||||
: duration === null
|
||||
? {}
|
||||
: { expiresAfterSeconds: duration }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -493,8 +583,13 @@ 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)
|
||||
value === 'restoration' ||
|
||||
[
|
||||
'restore_health',
|
||||
'restore_armor',
|
||||
'restore_ammo',
|
||||
'restore_signature_ability_charge',
|
||||
].includes(value)
|
||||
) {
|
||||
return 'restoration';
|
||||
}
|
||||
@@ -521,7 +616,7 @@ function parseOutcome(
|
||||
ruleId: string,
|
||||
poolKeys: Map<string, string>,
|
||||
index: number,
|
||||
errors: string[]
|
||||
errors: string[],
|
||||
): { clauseId: string | null; outcome: MechanicOutcome } | null {
|
||||
const path = `Outcome ${index + 1}`;
|
||||
if (text(row.rule_id) !== ruleId) {
|
||||
@@ -534,33 +629,67 @@ function parseOutcome(
|
||||
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.`);
|
||||
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)) {
|
||||
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)) {
|
||||
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);
|
||||
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.`);
|
||||
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.`);
|
||||
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'
|
||||
const legacyResource =
|
||||
text(row.application_kind) === 'restore_health'
|
||||
? 'health'
|
||||
: text(row.application_kind) === 'restore_armor'
|
||||
? 'armor'
|
||||
@@ -573,13 +702,19 @@ function parseOutcome(
|
||||
: null;
|
||||
const resource = nullableText(row.resource_kind) ?? legacyResource;
|
||||
if (
|
||||
(kind === 'restoration' || kind === 'resource_cost')
|
||||
&& !['health', 'armor', 'ammo', 'signature_ability_charge'].includes(String(resource))
|
||||
(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') {
|
||||
if (
|
||||
combatTiming &&
|
||||
combatTiming !== 'pre_event' &&
|
||||
combatTiming !== 'current'
|
||||
) {
|
||||
errors.push(`${path} has invalid combat timing.`);
|
||||
}
|
||||
const delivery = nullableText(row.delivery_mode) ?? 'instant';
|
||||
@@ -587,16 +722,27 @@ function parseOutcome(
|
||||
errors.push(`${path} has an unsupported delivery mode.`);
|
||||
}
|
||||
const durationSeconds = number(row.delivery_duration_seconds);
|
||||
if (delivery === 'continuous' && (durationSeconds === null || durationSeconds <= 0)) {
|
||||
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 !== '') {
|
||||
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) {
|
||||
if (
|
||||
parsed.ok &&
|
||||
typeof parsed.value === 'object' &&
|
||||
parsed.value !== null
|
||||
) {
|
||||
targeting = parsed.value as MechanicOutcome['targeting'];
|
||||
}
|
||||
}
|
||||
@@ -625,9 +771,13 @@ function parseOutcome(
|
||||
: {}),
|
||||
...(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 })
|
||||
}
|
||||
...(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 }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -638,29 +788,51 @@ function parseOutcome(
|
||||
*/
|
||||
export function adaptConditionalEffectProgram(
|
||||
input: MechanicPersistenceInput,
|
||||
options: MechanicPersistenceOptions = {}
|
||||
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']
|
||||
const simulationReadiness = [
|
||||
'ready',
|
||||
'requires_assumptions',
|
||||
'captured_only',
|
||||
].includes(readiness)
|
||||
? (readiness as MechanicPersistenceResult['simulationReadiness'])
|
||||
: null;
|
||||
if (!simulationReadiness) errors.push('Rule simulation readiness is invalid.');
|
||||
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 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);
|
||||
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 scenarioInputs = parseScenarioInputs(
|
||||
input.rule.scenario_inputs_json,
|
||||
errors,
|
||||
);
|
||||
|
||||
const variantKeys = [...new Set(input.outcomes.map((row) => text(row.variant_key)).filter(Boolean))];
|
||||
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(', ')}.`);
|
||||
errors.push(
|
||||
`A variant key is required because the rule has variants: ${variantKeys.join(', ')}.`,
|
||||
);
|
||||
} else {
|
||||
selectedVariant = variantKeys[0] ?? 'base';
|
||||
}
|
||||
@@ -669,18 +841,26 @@ export function adaptConditionalEffectProgram(
|
||||
}
|
||||
const parsedPools = input.statePools
|
||||
.slice()
|
||||
.sort((left, right) => (number(left.sort_order) ?? 0) - (number(right.sort_order) ?? 0))
|
||||
.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<typeof value> => value !== null);
|
||||
const poolKeys = new Map(parsedPools.map((pool) => [pool.id, pool.definition.key]));
|
||||
const poolKeys = new Map(
|
||||
parsedPools.map((pool) => [pool.id, pool.definition.key]),
|
||||
);
|
||||
const triggerMap = new Map(
|
||||
input.triggers.map((trigger) => [text(trigger.id), trigger])
|
||||
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))
|
||||
.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<typeof value> => value !== null);
|
||||
const outcomesByClause = new Map<string, MechanicOutcome[]>();
|
||||
@@ -698,9 +878,14 @@ export function adaptConditionalEffectProgram(
|
||||
const activeRows = input.clauses
|
||||
.filter((row) => enabled(row.active))
|
||||
.slice()
|
||||
.sort((left, right) => (number(left.sort_order) ?? 0) - (number(right.sort_order) ?? 0));
|
||||
.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.');
|
||||
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()) {
|
||||
@@ -712,16 +897,34 @@ export function adaptConditionalEffectProgram(
|
||||
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;
|
||||
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 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;
|
||||
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;
|
||||
@@ -734,8 +937,13 @@ export function adaptConditionalEffectProgram(
|
||||
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.`);
|
||||
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(
|
||||
@@ -745,20 +953,25 @@ export function adaptConditionalEffectProgram(
|
||||
conditionsJson.value,
|
||||
reducerJson.value,
|
||||
path,
|
||||
errors
|
||||
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 !== ''
|
||||
row.weight_expression_json !== null &&
|
||||
row.weight_expression_json !== undefined &&
|
||||
row.weight_expression_json !== ''
|
||||
) {
|
||||
const parsedWeight = parseJson(row.weight_expression_json, `${path} weight`, errors);
|
||||
const parsedWeight = parseJson(
|
||||
row.weight_expression_json,
|
||||
`${path} weight`,
|
||||
errors,
|
||||
);
|
||||
if (parsedWeight.ok) {
|
||||
if (isNumericExpression(parsedWeight.value)) weight = parsedWeight.value;
|
||||
if (isNumericExpression(parsedWeight.value))
|
||||
weight = parsedWeight.value;
|
||||
else errors.push(`${path} weight is not a valid numeric expression.`);
|
||||
}
|
||||
}
|
||||
@@ -768,19 +981,23 @@ export function adaptConditionalEffectProgram(
|
||||
label: text(row.label) || text(row.clause_key) || undefined,
|
||||
when: mapping.when,
|
||||
...(recipeKey ? { recipeKey } : {}),
|
||||
...(mapping.predicate && mapping.predicate.kind !== 'true' ? { if: mapping.predicate } : {}),
|
||||
...(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 } : {})
|
||||
...(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}".`);
|
||||
errors.push(
|
||||
`Outcomes reference missing or inactive clause "${clauseId}".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -788,11 +1005,19 @@ export function adaptConditionalEffectProgram(
|
||||
id: ruleId,
|
||||
name: options.name?.trim() || `${sourceType}:${sourceId}`,
|
||||
...(scenarioInputs.length ? { scenarioInputs } : {}),
|
||||
...(parsedPools.length ? { pools: parsedPools.map((pool) => pool.definition) } : {}),
|
||||
clauses
|
||||
...(parsedPools.length
|
||||
? { pools: parsedPools.map((pool) => pool.definition) }
|
||||
: {}),
|
||||
clauses,
|
||||
};
|
||||
if (errors.length) {
|
||||
return { status: 'INVALID', simulationReadiness, program: null, errors, warnings };
|
||||
return {
|
||||
status: 'INVALID',
|
||||
simulationReadiness,
|
||||
program: null,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const validation = validateMechanicProgram(program);
|
||||
@@ -802,7 +1027,7 @@ export function adaptConditionalEffectProgram(
|
||||
simulationReadiness,
|
||||
program: null,
|
||||
errors: validation.errors,
|
||||
warnings
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
@@ -810,21 +1035,35 @@ export function adaptConditionalEffectProgram(
|
||||
status: 'INVALID',
|
||||
simulationReadiness,
|
||||
program: null,
|
||||
errors: ['The persisted mechanic contains a structure that the runtime cannot validate.'],
|
||||
warnings
|
||||
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 };
|
||||
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');
|
||||
const requiresAssumptions =
|
||||
simulationReadiness === 'requires_assumptions' ||
|
||||
scenarioInputs.some((item) => item.readiness === 'requires_assumption');
|
||||
return {
|
||||
status: requiresAssumptions ? 'REQUIRES_ASSUMPTIONS' : 'READY',
|
||||
simulationReadiness,
|
||||
program,
|
||||
errors: [],
|
||||
warnings
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@ import {
|
||||
TRIGGER_ACTION_KEYS,
|
||||
validateTriggerConfiguration,
|
||||
type TriggerActionKey,
|
||||
type TriggerConfiguration
|
||||
type TriggerConfiguration,
|
||||
} from './conditional.js';
|
||||
import {
|
||||
createMechanicRecipeForTrigger,
|
||||
MECHANIC_RECIPE_REGISTRY,
|
||||
type MechanicRecipeSeed
|
||||
type MechanicRecipeSeed,
|
||||
} from './mechanic-recipes.js';
|
||||
import { validateMechanicProgram } from './mechanic.js';
|
||||
|
||||
@@ -21,8 +21,8 @@ const paramsByAction: Record<TriggerActionKey, unknown> = {
|
||||
health_threshold_bands: {
|
||||
bands: [
|
||||
{ thresholdPercent: 25, outcomeKey: 'critical' },
|
||||
{ thresholdPercent: 50, outcomeKey: 'damaged' }
|
||||
]
|
||||
{ thresholdPercent: 50, outcomeKey: 'damaged' },
|
||||
],
|
||||
},
|
||||
headshot: {},
|
||||
body_shot: {},
|
||||
@@ -33,7 +33,7 @@ const paramsByAction: Record<TriggerActionKey, unknown> = {
|
||||
weapon_swap: {},
|
||||
reload: {},
|
||||
within_distance: { meters: 10 },
|
||||
extra_health_active: {}
|
||||
extra_health_active: {},
|
||||
};
|
||||
|
||||
function triggerFor(action: TriggerActionKey): TriggerConfiguration {
|
||||
@@ -51,12 +51,18 @@ function seedFor(action: TriggerActionKey): MechanicRecipeSeed {
|
||||
|
||||
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());
|
||||
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);
|
||||
if (result.status === 'unsupported')
|
||||
expect(result.reason.trim()).not.toBe('');
|
||||
else
|
||||
expect(
|
||||
result.seed.clauses.every((clause) => clause.recipeKey === action),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -66,7 +72,7 @@ describe('mechanic recipe registry', () => {
|
||||
const validation = validateMechanicProgram({
|
||||
id: `recipe-${action}`,
|
||||
name: `Recipe ${action}`,
|
||||
...seed
|
||||
...seed,
|
||||
});
|
||||
expect(validation, action).toMatchObject({ ok: true });
|
||||
}
|
||||
@@ -77,81 +83,130 @@ describe('mechanic recipe registry', () => {
|
||||
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' }
|
||||
}
|
||||
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' }
|
||||
}
|
||||
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' }
|
||||
}
|
||||
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) {
|
||||
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: 'exists',
|
||||
value: { source: 'event', path: 'values.headshot' },
|
||||
},
|
||||
{
|
||||
kind: 'compare',
|
||||
left: { source: 'event', path: 'values.headshot' },
|
||||
operator: 'eq',
|
||||
right: headshot
|
||||
}
|
||||
]
|
||||
}
|
||||
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.pools).toEqual([
|
||||
expect.objectContaining({ key: 'firing-active', max: 1 }),
|
||||
]);
|
||||
expect(continuous.clauses.map((clause) => clause.when)).toEqual([
|
||||
'firing_started', 'firing_stopped', 'reload_started', 'tick'
|
||||
'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 }
|
||||
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'
|
||||
]);
|
||||
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.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'
|
||||
'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 }
|
||||
])
|
||||
{
|
||||
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' },
|
||||
left: {
|
||||
source: 'combat',
|
||||
path: 'currentHealthPercent',
|
||||
timing: 'current',
|
||||
},
|
||||
operator: 'gte',
|
||||
right: 50
|
||||
right: 50,
|
||||
});
|
||||
|
||||
expect(seedFor('target_hit_within_distance').clauses[0]).toMatchObject({
|
||||
@@ -160,27 +215,44 @@ describe('mechanic recipe registry', () => {
|
||||
kind: 'all',
|
||||
predicates: expect.arrayContaining([
|
||||
{ kind: 'exists', value: { source: 'event', path: 'targetId' } },
|
||||
{ kind: 'compare', left: { source: 'event', path: 'values.distanceMeters' }, operator: 'lte', right: 15 }
|
||||
])
|
||||
}
|
||||
{
|
||||
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'
|
||||
'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
|
||||
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'
|
||||
'enter-extra-health-active',
|
||||
'exit-extra-health-active',
|
||||
]);
|
||||
expect(extraHealth.clauses.every((clause) => clause.when === 'extra_health_changed')).toBe(true);
|
||||
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
|
||||
kind: 'compare',
|
||||
left: { source: 'event', path: 'values.hasExtraHealth' },
|
||||
operator: 'eq',
|
||||
right: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
SameTargetHitsParams,
|
||||
TriggerActionKey,
|
||||
TriggerConfiguration,
|
||||
TriggerParamsByAction
|
||||
TriggerParamsByAction,
|
||||
} from './conditional.js';
|
||||
import type {
|
||||
MechanicClause,
|
||||
@@ -15,11 +15,14 @@ import type {
|
||||
MechanicProgram,
|
||||
PoolDefinition,
|
||||
Predicate,
|
||||
ValueReference
|
||||
ValueReference,
|
||||
} from './mechanic.js';
|
||||
|
||||
/** A partial program that an editor can merge into a new mechanic draft. */
|
||||
export type MechanicRecipeSeed = Pick<MechanicProgram, 'scenarioInputs' | 'pools' | 'clauses'>;
|
||||
export type MechanicRecipeSeed = Pick<
|
||||
MechanicProgram,
|
||||
'scenarioInputs' | 'pools' | 'clauses'
|
||||
>;
|
||||
|
||||
export type SupportedMechanicRecipe = {
|
||||
status: 'supported';
|
||||
@@ -31,9 +34,12 @@ export type UnsupportedMechanicRecipe = {
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type MechanicRecipeResult = SupportedMechanicRecipe | UnsupportedMechanicRecipe;
|
||||
export type MechanicRecipeResult =
|
||||
SupportedMechanicRecipe | UnsupportedMechanicRecipe;
|
||||
|
||||
export type MechanicRecipeDefinition<K extends TriggerActionKey = TriggerActionKey> = {
|
||||
export type MechanicRecipeDefinition<
|
||||
K extends TriggerActionKey = TriggerActionKey,
|
||||
> = {
|
||||
key: K;
|
||||
createSeed: (params: TriggerParamsByAction[K]) => MechanicRecipeResult;
|
||||
};
|
||||
@@ -42,54 +48,71 @@ export type MechanicRecipeRegistry = {
|
||||
[K in TriggerActionKey]: MechanicRecipeDefinition<K>;
|
||||
};
|
||||
|
||||
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' }
|
||||
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
|
||||
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[] = [{
|
||||
function firingLifecycleSeed(
|
||||
activePool: string,
|
||||
includeDuration?: number,
|
||||
): MechanicRecipeSeed {
|
||||
const pools: PoolDefinition[] = [
|
||||
{
|
||||
key: activePool,
|
||||
label: 'Firing active',
|
||||
scope: 'agent',
|
||||
initial: 0,
|
||||
min: 0,
|
||||
max: 1
|
||||
}];
|
||||
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 }]
|
||||
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 }]
|
||||
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 }]
|
||||
}
|
||||
mutations: [{ kind: 'clear', pool: activePool }],
|
||||
},
|
||||
];
|
||||
if (includeDuration !== undefined) {
|
||||
clauses.push({
|
||||
@@ -100,21 +123,23 @@ function firingLifecycleSeed(activePool: string, includeDuration?: number): Mech
|
||||
kind: 'compare',
|
||||
left: { source: 'pool', pool: activePool },
|
||||
operator: 'gt',
|
||||
right: 0
|
||||
right: 0,
|
||||
},
|
||||
reducer: { kind: 'duration', seconds: includeDuration },
|
||||
mutations: noMutation
|
||||
mutations: noMutation,
|
||||
});
|
||||
}
|
||||
return { pools, clauses };
|
||||
}
|
||||
|
||||
function healthBandSeed(params: HealthThresholdParams): MechanicRecipeSeed {
|
||||
const sortedBands = [...params.bands].sort((left, right) => left.thresholdPercent - right.thresholdPercent);
|
||||
const sortedBands = [...params.bands].sort(
|
||||
(left, right) => left.thresholdPercent - right.thresholdPercent,
|
||||
);
|
||||
const currentHealthPercent: ValueReference = {
|
||||
source: 'combat',
|
||||
path: 'currentHealthPercent',
|
||||
timing: 'current'
|
||||
timing: 'current',
|
||||
};
|
||||
const pools: PoolDefinition[] = sortedBands.map((band, index) => ({
|
||||
key: `health-band-${index + 1}`,
|
||||
@@ -122,22 +147,24 @@ function healthBandSeed(params: HealthThresholdParams): MechanicRecipeSeed {
|
||||
scope: 'agent',
|
||||
initial: 0,
|
||||
min: 0,
|
||||
max: 1
|
||||
max: 1,
|
||||
}));
|
||||
const clauses: MechanicClause[] = sortedBands.map((band, index) => {
|
||||
const lowerBound = sortedBands[index - 1]?.thresholdPercent;
|
||||
const predicates: Predicate[] = [{
|
||||
const predicates: Predicate[] = [
|
||||
{
|
||||
kind: 'compare',
|
||||
left: currentHealthPercent,
|
||||
operator: 'lt',
|
||||
right: band.thresholdPercent
|
||||
}];
|
||||
right: band.thresholdPercent,
|
||||
},
|
||||
];
|
||||
if (lowerBound !== undefined) {
|
||||
predicates.push({
|
||||
kind: 'compare',
|
||||
left: currentHealthPercent,
|
||||
operator: 'gte',
|
||||
right: lowerBound
|
||||
right: lowerBound,
|
||||
});
|
||||
}
|
||||
return {
|
||||
@@ -149,8 +176,8 @@ function healthBandSeed(params: HealthThresholdParams): MechanicRecipeSeed {
|
||||
mutations: pools.map((pool, poolIndex) =>
|
||||
poolIndex === index
|
||||
? { kind: 'set' as const, pool: pool.key, value: 1 }
|
||||
: { kind: 'clear' as const, pool: pool.key }
|
||||
)
|
||||
: { kind: 'clear' as const, pool: pool.key },
|
||||
),
|
||||
};
|
||||
});
|
||||
clauses.push({
|
||||
@@ -161,10 +188,13 @@ function healthBandSeed(params: HealthThresholdParams): MechanicRecipeSeed {
|
||||
kind: 'compare',
|
||||
left: currentHealthPercent,
|
||||
operator: 'gte',
|
||||
right: sortedBands.at(-1)?.thresholdPercent ?? 0
|
||||
right: sortedBands.at(-1)?.thresholdPercent ?? 0,
|
||||
},
|
||||
reducer: { kind: 'none' },
|
||||
mutations: pools.map((pool) => ({ kind: 'clear' as const, pool: pool.key }))
|
||||
mutations: pools.map((pool) => ({
|
||||
kind: 'clear' as const,
|
||||
pool: pool.key,
|
||||
})),
|
||||
});
|
||||
return { pools, clauses };
|
||||
}
|
||||
@@ -173,7 +203,7 @@ function eventLifecycleSeed(
|
||||
pool: PoolDefinition,
|
||||
event: MechanicEventKind,
|
||||
activePredicate: Predicate,
|
||||
label: string
|
||||
label: string,
|
||||
): MechanicRecipeSeed {
|
||||
return {
|
||||
pools: [pool],
|
||||
@@ -184,7 +214,7 @@ function eventLifecycleSeed(
|
||||
when: event,
|
||||
if: activePredicate,
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [{ kind: 'set', pool: pool.key, value: 1 }]
|
||||
mutations: [{ kind: 'set', pool: pool.key, value: 1 }],
|
||||
},
|
||||
{
|
||||
id: `exit-${pool.key}`,
|
||||
@@ -192,9 +222,9 @@ function eventLifecycleSeed(
|
||||
when: event,
|
||||
if: { kind: 'not', predicate: activePredicate },
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [{ kind: 'clear', pool: pool.key }]
|
||||
}
|
||||
]
|
||||
mutations: [{ kind: 'clear', pool: pool.key }],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -203,134 +233,239 @@ export const MECHANIC_RECIPE_REGISTRY: Readonly<MechanicRecipeRegistry> = {
|
||||
key: 'same_target_hits_within_window',
|
||||
createSeed: (params) => {
|
||||
const value = params as SameTargetHitsParams;
|
||||
return supported({ clauses: [{
|
||||
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' }
|
||||
kind: 'sliding_count',
|
||||
threshold: value.hitsRequired,
|
||||
windowSeconds: value.windowSeconds,
|
||||
reset: 'zero',
|
||||
groupBy: { source: 'event', path: 'targetId' },
|
||||
},
|
||||
mutations: noMutation,
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
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,
|
||||
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' }
|
||||
kind: 'distinct_count',
|
||||
threshold: value.enemiesRequired,
|
||||
windowSeconds: value.windowSeconds,
|
||||
distinctBy: { source: 'event', path: 'targetId' },
|
||||
},
|
||||
mutations: noMutation,
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
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,
|
||||
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' }
|
||||
kind: 'consecutive_count',
|
||||
threshold: value.hitsRequired,
|
||||
groupBy: { source: 'event', path: 'targetId' },
|
||||
},
|
||||
mutations: noMutation,
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
mutations: noMutation
|
||||
}] });
|
||||
}
|
||||
},
|
||||
continuous_firing_duration: {
|
||||
key: 'continuous_firing_duration',
|
||||
createSeed: (params) => supported(firingLifecycleSeed('firing-active', (params as ContinuousFiringParams).seconds))
|
||||
createSeed: (params) =>
|
||||
supported(
|
||||
firingLifecycleSeed(
|
||||
'firing-active',
|
||||
(params as ContinuousFiringParams).seconds,
|
||||
),
|
||||
),
|
||||
},
|
||||
while_firing: {
|
||||
key: 'while_firing',
|
||||
createSeed: (_params) => supported(firingLifecycleSeed('firing-active'))
|
||||
createSeed: (_params) => supported(firingLifecycleSeed('firing-active')),
|
||||
},
|
||||
health_threshold_bands: {
|
||||
key: 'health_threshold_bands',
|
||||
createSeed: (params) => supported(healthBandSeed(params as HealthThresholdParams))
|
||||
createSeed: (params) =>
|
||||
supported(healthBandSeed(params as HealthThresholdParams)),
|
||||
},
|
||||
headshot: {
|
||||
key: 'headshot',
|
||||
createSeed: (_params) => supported({ clauses: [directClause('headshot', 'weapon_hit', {
|
||||
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 }
|
||||
]
|
||||
})] })
|
||||
{
|
||||
kind: 'compare',
|
||||
left: eventValue('headshot'),
|
||||
operator: 'eq',
|
||||
right: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
},
|
||||
body_shot: {
|
||||
key: 'body_shot',
|
||||
createSeed: (_params) => supported({ clauses: [directClause('body-shot', 'weapon_hit', {
|
||||
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 }
|
||||
]
|
||||
})] })
|
||||
{
|
||||
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
|
||||
})] })
|
||||
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')] })
|
||||
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
|
||||
}]
|
||||
})] });
|
||||
}
|
||||
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')] })
|
||||
createSeed: (_params) =>
|
||||
supported({
|
||||
clauses: [directClause('damage-taken', 'health_damage_taken')],
|
||||
}),
|
||||
},
|
||||
weapon_swap: {
|
||||
key: 'weapon_swap',
|
||||
createSeed: (_params) => supported({ clauses: [directClause('weapon-swap', 'weapon_swapped')] })
|
||||
createSeed: (_params) =>
|
||||
supported({ clauses: [directClause('weapon-swap', 'weapon_swapped')] }),
|
||||
},
|
||||
reload: {
|
||||
key: 'reload',
|
||||
createSeed: (_params) => supported({ clauses: [directClause('reload-started', 'reload_started')] })
|
||||
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 },
|
||||
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`
|
||||
));
|
||||
}
|
||||
{
|
||||
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 },
|
||||
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'
|
||||
))
|
||||
}
|
||||
{
|
||||
kind: 'compare',
|
||||
left: eventValue('hasExtraHealth'),
|
||||
operator: 'eq',
|
||||
right: true,
|
||||
},
|
||||
'Extra Health active',
|
||||
),
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -339,9 +474,11 @@ export const MECHANIC_RECIPE_REGISTRY: Readonly<MechanicRecipeRegistry> = {
|
||||
*/
|
||||
export function createMechanicRecipe<K extends TriggerActionKey>(
|
||||
action: K,
|
||||
params: TriggerParamsByAction[K]
|
||||
params: TriggerParamsByAction[K],
|
||||
): MechanicRecipeResult {
|
||||
const definition = MECHANIC_RECIPE_REGISTRY[action] as MechanicRecipeDefinition<K>;
|
||||
const definition = MECHANIC_RECIPE_REGISTRY[
|
||||
action
|
||||
] as MechanicRecipeDefinition<K>;
|
||||
const result = definition.createSeed(params);
|
||||
if (result.status === 'unsupported') return result;
|
||||
return {
|
||||
@@ -350,12 +487,14 @@ export function createMechanicRecipe<K extends TriggerActionKey>(
|
||||
...result.seed,
|
||||
clauses: result.seed.clauses.map((clause) => ({
|
||||
...clause,
|
||||
recipeKey: action
|
||||
}))
|
||||
}
|
||||
recipeKey: action,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createMechanicRecipeForTrigger(trigger: TriggerConfiguration): MechanicRecipeResult {
|
||||
export function createMechanicRecipeForTrigger(
|
||||
trigger: TriggerConfiguration,
|
||||
): MechanicRecipeResult {
|
||||
return createMechanicRecipe(trigger.action, trigger.params);
|
||||
}
|
||||
|
||||
+1106
-538
File diff suppressed because it is too large
Load Diff
+1160
-460
File diff suppressed because it is too large
Load Diff
@@ -2,11 +2,11 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
calculateSkillDps,
|
||||
type SkillAbilityInput,
|
||||
type SkillCalculationInput
|
||||
type SkillCalculationInput,
|
||||
} from './skill.js';
|
||||
|
||||
const directAbility = (
|
||||
overrides: Partial<SkillAbilityInput> = {}
|
||||
overrides: Partial<SkillAbilityInput> = {},
|
||||
): SkillAbilityInput => ({
|
||||
id: 'seeker',
|
||||
baseDamage: 100,
|
||||
@@ -17,19 +17,37 @@ const directAbility = (
|
||||
targetCap: 3,
|
||||
chargePolicy: { type: 'none' },
|
||||
activation: { mode: 'on_cooldown' },
|
||||
...overrides
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const base = (overrides: Partial<SkillCalculationInput> = {}): SkillCalculationInput => ({
|
||||
const base = (
|
||||
overrides: Partial<SkillCalculationInput> = {},
|
||||
): 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' }
|
||||
]
|
||||
{
|
||||
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: {
|
||||
@@ -37,10 +55,10 @@ const base = (overrides: Partial<SkillCalculationInput> = {}): SkillCalculationI
|
||||
criticalChance: 25,
|
||||
criticalDamage: 50,
|
||||
multishotChance: 20,
|
||||
multishotDamage: 100
|
||||
multishotDamage: 100,
|
||||
},
|
||||
assumptions: { encounterSeconds: 10, targetCount: 5 },
|
||||
...overrides
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('calculateSkillDps', () => {
|
||||
@@ -63,26 +81,38 @@ describe('calculateSkillDps', () => {
|
||||
});
|
||||
|
||||
it('reads current modifiers on every periodic tick instead of snapshotting at cast time', () => {
|
||||
const result = calculateSkillDps(base({
|
||||
abilities: [directAbility({
|
||||
const result = calculateSkillDps(
|
||||
base({
|
||||
abilities: [
|
||||
directAbility({
|
||||
id: 'turret',
|
||||
delivery: { type: 'periodic', ticks: 4, intervalSeconds: 1 },
|
||||
cooldownSeconds: 100,
|
||||
targetCap: 1
|
||||
})],
|
||||
targetCap: 1,
|
||||
}),
|
||||
],
|
||||
attributes: {},
|
||||
rules: {
|
||||
revisionId: 'dynamic-ticks',
|
||||
bindings: [{ attributeId: 'skillDamage', stage: 'skill_damage', operator: 'add' }]
|
||||
bindings: [
|
||||
{
|
||||
attributeId: 'skillDamage',
|
||||
stage: 'skill_damage',
|
||||
operator: 'add',
|
||||
},
|
||||
modifierWindows: [{
|
||||
],
|
||||
},
|
||||
modifierWindows: [
|
||||
{
|
||||
startSeconds: 2,
|
||||
endSeconds: 4,
|
||||
attributes: { skillDamage: 100 },
|
||||
abilityIds: ['turret']
|
||||
}],
|
||||
assumptions: { encounterSeconds: 5, targetCount: 1 }
|
||||
}));
|
||||
abilityIds: ['turret'],
|
||||
},
|
||||
],
|
||||
assumptions: { encounterSeconds: 5, targetCount: 1 },
|
||||
}),
|
||||
);
|
||||
expect(result.status).toBe('OK');
|
||||
if (result.status !== 'OK') return;
|
||||
|
||||
@@ -91,17 +121,26 @@ describe('calculateSkillDps', () => {
|
||||
});
|
||||
|
||||
it('supports multiple delivered hits on each periodic tick', () => {
|
||||
const result = calculateSkillDps(base({
|
||||
abilities: [directAbility({
|
||||
const result = calculateSkillDps(
|
||||
base({
|
||||
abilities: [
|
||||
directAbility({
|
||||
id: 'multi-hit-dot',
|
||||
delivery: { type: 'periodic', ticks: 2, hitsPerTick: 3, intervalSeconds: 1 },
|
||||
delivery: {
|
||||
type: 'periodic',
|
||||
ticks: 2,
|
||||
hitsPerTick: 3,
|
||||
intervalSeconds: 1,
|
||||
},
|
||||
cooldownSeconds: 100,
|
||||
targetCap: 1
|
||||
})],
|
||||
targetCap: 1,
|
||||
}),
|
||||
],
|
||||
attributes: {},
|
||||
rules: { revisionId: 'periodic-hits', bindings: [] },
|
||||
assumptions: { encounterSeconds: 5, targetCount: 1 }
|
||||
}));
|
||||
assumptions: { encounterSeconds: 5, targetCount: 1 },
|
||||
}),
|
||||
);
|
||||
expect(result.status).toBe('OK');
|
||||
if (result.status !== 'OK') return;
|
||||
|
||||
@@ -110,29 +149,37 @@ describe('calculateSkillDps', () => {
|
||||
});
|
||||
|
||||
it('applies published cooldown recovery and duration stages to cadence', () => {
|
||||
const result = calculateSkillDps(base({
|
||||
abilities: [directAbility({
|
||||
const result = calculateSkillDps(
|
||||
base({
|
||||
abilities: [
|
||||
directAbility({
|
||||
id: 'cadence',
|
||||
delivery: {
|
||||
type: 'periodic',
|
||||
ticks: 2,
|
||||
hitsPerTick: 1,
|
||||
intervalSeconds: 1,
|
||||
firstTickDelaySeconds: 1
|
||||
firstTickDelaySeconds: 1,
|
||||
},
|
||||
cooldownSeconds: 10,
|
||||
targetCap: 1
|
||||
})],
|
||||
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' }
|
||||
]
|
||||
{
|
||||
attributeId: 'recovery',
|
||||
stage: 'cooldown_recovery',
|
||||
operator: 'add',
|
||||
},
|
||||
assumptions: { encounterSeconds: 11, targetCount: 1 }
|
||||
}));
|
||||
{ attributeId: 'duration', stage: 'duration', operator: 'add' },
|
||||
],
|
||||
},
|
||||
assumptions: { encounterSeconds: 11, targetCount: 1 },
|
||||
}),
|
||||
);
|
||||
expect(result.status).toBe('OK');
|
||||
if (result.status !== 'OK') return;
|
||||
|
||||
@@ -142,17 +189,21 @@ describe('calculateSkillDps', () => {
|
||||
});
|
||||
|
||||
it('supports custom uses per minute and clips damage events at encounter end', () => {
|
||||
const result = calculateSkillDps(base({
|
||||
abilities: [directAbility({
|
||||
const result = calculateSkillDps(
|
||||
base({
|
||||
abilities: [
|
||||
directAbility({
|
||||
delivery: { type: 'periodic', ticks: 3, intervalSeconds: 2 },
|
||||
cooldownSeconds: 0,
|
||||
targetCap: 1,
|
||||
activation: { mode: 'custom_per_minute', usesPerMinute: 4 }
|
||||
})],
|
||||
activation: { mode: 'custom_per_minute', usesPerMinute: 4 },
|
||||
}),
|
||||
],
|
||||
attributes: {},
|
||||
rules: { revisionId: 'custom-rate', bindings: [] },
|
||||
assumptions: { encounterSeconds: 31, targetCount: 1 }
|
||||
}));
|
||||
assumptions: { encounterSeconds: 31, targetCount: 1 },
|
||||
}),
|
||||
);
|
||||
expect(result.status).toBe('OK');
|
||||
if (result.status !== 'OK') return;
|
||||
|
||||
@@ -167,93 +218,120 @@ describe('calculateSkillDps', () => {
|
||||
castSeconds: 2,
|
||||
cooldownSeconds: 10,
|
||||
targetCap: 1,
|
||||
activation: { mode: 'on_cooldown' } as const
|
||||
activation: { mode: 'on_cooldown' } as const,
|
||||
};
|
||||
const result = calculateSkillDps(base({
|
||||
const result = calculateSkillDps(
|
||||
base({
|
||||
abilities: [
|
||||
directAbility({
|
||||
...shared,
|
||||
id: 'independent',
|
||||
chargePolicy: { type: 'independent', charges: 3 }
|
||||
chargePolicy: { type: 'independent', charges: 3 },
|
||||
}),
|
||||
directAbility({
|
||||
...shared,
|
||||
id: 'full-set',
|
||||
chargePolicy: { type: 'full_set', charges: 3 }
|
||||
})
|
||||
chargePolicy: { type: 'full_set', charges: 3 },
|
||||
}),
|
||||
],
|
||||
attributes: {},
|
||||
rules: { revisionId: 'charges', bindings: [] },
|
||||
assumptions: { encounterSeconds: 13, targetCount: 1 }
|
||||
}));
|
||||
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({
|
||||
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: 'two',
|
||||
delivery: { type: 'direct', hits: 1 },
|
||||
baseDamage: 200,
|
||||
}),
|
||||
directAbility({
|
||||
id: 'off',
|
||||
baseDamage: undefined,
|
||||
activation: { mode: 'off' }
|
||||
})
|
||||
activation: { mode: 'off' },
|
||||
}),
|
||||
],
|
||||
attributes: {},
|
||||
rules: { revisionId: 'combined', bindings: [] },
|
||||
assumptions: { encounterSeconds: 10, targetCount: 1 }
|
||||
}));
|
||||
assumptions: { encounterSeconds: 10, targetCount: 1 },
|
||||
}),
|
||||
);
|
||||
expect(result.status).toBe('OK');
|
||||
if (result.status !== 'OK') return;
|
||||
|
||||
expect(result.abilities.find((item) => item.abilityId === 'off')).toMatchObject({
|
||||
expect(
|
||||
result.abilities.find((item) => item.abilityId === 'off'),
|
||||
).toMatchObject({
|
||||
enabled: false,
|
||||
activationCount: 0
|
||||
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({
|
||||
const result = calculateSkillDps(
|
||||
base({
|
||||
abilities: [
|
||||
directAbility({ id: 'single', targetCount: 1 }),
|
||||
directAbility({ id: 'crowd', targetCount: 2 })
|
||||
directAbility({ id: 'crowd', targetCount: 2 }),
|
||||
],
|
||||
attributes: {},
|
||||
rules: { revisionId: 'per-ability-targets', bindings: [] },
|
||||
assumptions: { encounterSeconds: 10, targetCount: 5 }
|
||||
}));
|
||||
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({
|
||||
abilities: [
|
||||
directAbility({
|
||||
baseDamage: undefined,
|
||||
targetCap: undefined,
|
||||
chargePolicy: undefined,
|
||||
activation: { mode: 'custom_per_minute', usesPerMinute: 0 }
|
||||
})],
|
||||
activation: { mode: 'custom_per_minute', usesPerMinute: 0 },
|
||||
}),
|
||||
],
|
||||
attributes: { unknownSkillAttribute: 10 },
|
||||
assumptions: {}
|
||||
assumptions: {},
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
status: 'INSUFFICIENT_DATA',
|
||||
revisionId: null,
|
||||
abilities: [],
|
||||
combined: null
|
||||
combined: null,
|
||||
});
|
||||
expect(result.missing).toEqual(expect.arrayContaining([
|
||||
expect(result.missing).toEqual(
|
||||
expect.arrayContaining([
|
||||
'rules.revisionId',
|
||||
'rules.bindings',
|
||||
'assumptions.encounterSeconds',
|
||||
@@ -262,7 +340,8 @@ describe('calculateSkillDps', () => {
|
||||
'ability:seeker:targetCap',
|
||||
'ability:seeker:chargePolicy',
|
||||
'ability:seeker:activation.usesPerMinute',
|
||||
'binding:unknownSkillAttribute'
|
||||
]));
|
||||
'binding:unknownSkillAttribute',
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+194
-89
@@ -69,10 +69,13 @@ export type SkillCalculationInput = {
|
||||
rules?: SkillCalculationRules;
|
||||
abilities: SkillAbilityInput[];
|
||||
attributes?: SkillStatValues;
|
||||
attributeRules?: Record<string, {
|
||||
attributeRules?: Record<
|
||||
string,
|
||||
{
|
||||
combination: 'additive' | 'multiplicative_percent';
|
||||
cap?: number | null;
|
||||
}>;
|
||||
}
|
||||
>;
|
||||
modifierWindows?: SkillModifierWindow[];
|
||||
assumptions: {
|
||||
encounterSeconds?: number;
|
||||
@@ -133,21 +136,28 @@ 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));
|
||||
|
||||
function requiredData(input: SkillCalculationInput) {
|
||||
const missing: string[] = [];
|
||||
const enabled = input.abilities.filter((ability) => ability.activation.mode !== 'off');
|
||||
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 (!isPositive(input.assumptions.encounterSeconds))
|
||||
missing.push('assumptions.encounterSeconds');
|
||||
if (!isPositiveInteger(input.assumptions.targetCount))
|
||||
missing.push('assumptions.targetCount');
|
||||
}
|
||||
|
||||
const seenIds = new Set<string>();
|
||||
@@ -158,23 +168,34 @@ function requiredData(input: SkillCalculationInput) {
|
||||
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)) {
|
||||
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)) {
|
||||
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`);
|
||||
if (!isPositiveInteger(ability.delivery.hits))
|
||||
missing.push(`${prefix}:delivery.hits`);
|
||||
} else {
|
||||
if (!isPositiveInteger(ability.delivery.ticks)) missing.push(`${prefix}:delivery.ticks`);
|
||||
if (!isPositiveInteger(ability.delivery.ticks))
|
||||
missing.push(`${prefix}:delivery.ticks`);
|
||||
if (
|
||||
ability.delivery.durationSeconds !== undefined
|
||||
&& !isPositive(ability.delivery.durationSeconds)
|
||||
ability.delivery.durationSeconds !== undefined &&
|
||||
!isPositive(ability.delivery.durationSeconds)
|
||||
) {
|
||||
missing.push(`${prefix}:delivery.durationSeconds`);
|
||||
}
|
||||
@@ -189,15 +210,15 @@ function requiredData(input: SkillCalculationInput) {
|
||||
}
|
||||
}
|
||||
if (
|
||||
ability.activation.mode === 'custom_per_minute'
|
||||
&& !isPositive(ability.activation.usesPerMinute)
|
||||
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)
|
||||
ability.activation.mode === 'on_cooldown' &&
|
||||
!isPositive(ability.cooldownSeconds) &&
|
||||
!isPositive(ability.castSeconds)
|
||||
) {
|
||||
missing.push(`${prefix}:activation.cadence`);
|
||||
}
|
||||
@@ -207,11 +228,15 @@ function requiredData(input: SkillCalculationInput) {
|
||||
if (!isNonNegative(window.startSeconds)) {
|
||||
missing.push(`modifierWindow:${index}:startSeconds`);
|
||||
}
|
||||
if (!isPositive(window.endSeconds) || window.endSeconds <= window.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}`);
|
||||
if (!isFiniteNumber(value))
|
||||
missing.push(`modifierWindow:${index}:attribute:${attributeId}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,10 +244,14 @@ function requiredData(input: SkillCalculationInput) {
|
||||
if (!isFiniteNumber(value)) missing.push(`attribute:${attributeId}`);
|
||||
}
|
||||
|
||||
const boundIds = new Set(input.rules?.bindings.map((binding) => 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))
|
||||
...(input.modifierWindows ?? []).flatMap((window) =>
|
||||
Object.keys(window.attributes),
|
||||
),
|
||||
]);
|
||||
for (const attributeId of suppliedIds) {
|
||||
if (!boundIds.has(attributeId)) missing.push(`binding:${attributeId}`);
|
||||
@@ -242,9 +271,13 @@ function stageFactorAt(
|
||||
rules: SkillCalculationRules,
|
||||
abilityId: string,
|
||||
time: number,
|
||||
stage: 'cooldown_recovery' | 'duration'
|
||||
stage: 'cooldown_recovery' | 'duration',
|
||||
) {
|
||||
const values = stageValues(attributesAt(input, abilityId, time), rules.bindings, stage);
|
||||
const values = stageValues(
|
||||
attributesAt(input, abilityId, time),
|
||||
rules.bindings,
|
||||
stage,
|
||||
);
|
||||
return Math.max(EPSILON, (1 + values.additive / 100) * values.multiplicative);
|
||||
}
|
||||
|
||||
@@ -252,13 +285,14 @@ export function skillActivationTimes(
|
||||
ability: SkillAbilityInput,
|
||||
encounterSeconds: number,
|
||||
input: SkillCalculationInput,
|
||||
rules: SkillCalculationRules
|
||||
rules: SkillCalculationRules,
|
||||
) {
|
||||
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 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[] = [];
|
||||
@@ -280,19 +314,21 @@ export function skillActivationTimes(
|
||||
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
|
||||
(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
|
||||
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;
|
||||
if (ability.activation.mode === 'custom_per_minute')
|
||||
nextRequested = time + requestedInterval;
|
||||
}
|
||||
return times;
|
||||
}
|
||||
@@ -302,7 +338,7 @@ export function skillActivationTimes(
|
||||
while (times.length < MAX_EVENTS) {
|
||||
let time = Math.max(
|
||||
lastActivation + castSeconds,
|
||||
ability.activation.mode === 'custom_per_minute' ? nextRequested : 0
|
||||
ability.activation.mode === 'custom_per_minute' ? nextRequested : 0,
|
||||
);
|
||||
if (charges === 0) {
|
||||
time = Math.max(time, fullRechargeAt);
|
||||
@@ -314,7 +350,8 @@ export function skillActivationTimes(
|
||||
lastActivation = time;
|
||||
charges -= 1;
|
||||
if (charges === 0) fullRechargeAt = time + cooldownAt(time);
|
||||
if (ability.activation.mode === 'custom_per_minute') nextRequested = time + requestedInterval;
|
||||
if (ability.activation.mode === 'custom_per_minute')
|
||||
nextRequested = time + requestedInterval;
|
||||
}
|
||||
return times;
|
||||
}
|
||||
@@ -324,7 +361,7 @@ function damageEvents(
|
||||
activations: number[],
|
||||
encounterSeconds: number,
|
||||
input: SkillCalculationInput,
|
||||
rules: SkillCalculationRules
|
||||
rules: SkillCalculationRules,
|
||||
) {
|
||||
const events: DamageEvent[] = [];
|
||||
for (const [activationIndex, activationTime] of activations.entries()) {
|
||||
@@ -337,20 +374,33 @@ function damageEvents(
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const firstTick = deliveryTime + Number(ability.delivery.firstTickDelaySeconds ?? 0);
|
||||
const durationFactor = stageFactorAt(input, rules, ability.id, activationTime, 'duration');
|
||||
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
|
||||
ability.delivery.durationSeconds ??
|
||||
ability.delivery.ticks * ability.delivery.intervalSeconds,
|
||||
);
|
||||
const ticks = Math.max(
|
||||
1,
|
||||
Math.floor(baseDuration * durationFactor / ability.delivery.intervalSeconds)
|
||||
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) {
|
||||
for (
|
||||
let hit = 0;
|
||||
hit < Number(ability.delivery.hitsPerTick ?? 1);
|
||||
hit += 1
|
||||
) {
|
||||
events.push({ activationIndex, time });
|
||||
}
|
||||
}
|
||||
@@ -362,19 +412,27 @@ function damageEvents(
|
||||
function attributesAt(
|
||||
input: SkillCalculationInput,
|
||||
abilityId: string,
|
||||
time: number
|
||||
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;
|
||||
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'
|
||||
const combined =
|
||||
rule?.combination === 'multiplicative_percent'
|
||||
? ((1 + current / 100) * (1 + value / 100) - 1) * 100
|
||||
: current + value;
|
||||
attributes[attributeId] = rule?.cap === null || rule?.cap === undefined
|
||||
attributes[attributeId] =
|
||||
rule?.cap === null || rule?.cap === undefined
|
||||
? combined
|
||||
: Math.min(combined, rule.cap);
|
||||
}
|
||||
@@ -385,14 +443,15 @@ function attributesAt(
|
||||
function stageValues(
|
||||
attributes: SkillStatValues,
|
||||
bindings: SkillAttributeBinding[],
|
||||
stage: SkillCalculationStage
|
||||
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);
|
||||
const value =
|
||||
raw * (Number.isFinite(binding.weight) ? Number(binding.weight) : 1);
|
||||
if (binding.operator === 'add') additive += value;
|
||||
else multiplicative *= value;
|
||||
}
|
||||
@@ -402,12 +461,16 @@ function stageValues(
|
||||
function expectedDamage(
|
||||
baseDamage: number,
|
||||
attributes: SkillStatValues,
|
||||
rules: SkillCalculationRules
|
||||
rules: SkillCalculationRules,
|
||||
) {
|
||||
let damage = baseDamage;
|
||||
const stageOrder = rules.stageOrder?.length
|
||||
? rules.stageOrder
|
||||
: ['flat_damage', 'skill_damage', 'final_damage'] satisfies SkillCalculationStage[];
|
||||
: ([
|
||||
'flat_damage',
|
||||
'skill_damage',
|
||||
'final_damage',
|
||||
] satisfies SkillCalculationStage[]);
|
||||
|
||||
for (const stage of stageOrder) {
|
||||
const values = stageValues(attributes, rules.bindings, stage);
|
||||
@@ -418,28 +481,46 @@ function expectedDamage(
|
||||
}
|
||||
}
|
||||
|
||||
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 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,
|
||||
(criticalChance.additive * criticalChance.multiplicative) / 100,
|
||||
0,
|
||||
1
|
||||
1,
|
||||
);
|
||||
const criticalDamageBonus =
|
||||
criticalDamage.additive * criticalDamage.multiplicative / 100;
|
||||
(criticalDamage.additive * criticalDamage.multiplicative) / 100;
|
||||
const multishotChanceRate = clamp(
|
||||
multishotChance.additive * multishotChance.multiplicative / 100,
|
||||
(multishotChance.additive * multishotChance.multiplicative) / 100,
|
||||
0,
|
||||
1
|
||||
1,
|
||||
);
|
||||
const multishotDamageFactor =
|
||||
multishotDamage.additive * multishotDamage.multiplicative / 100;
|
||||
(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 {
|
||||
@@ -451,11 +532,13 @@ function zeroDamage(baseDamage = 0): SkillDamageSummary {
|
||||
singleTargetEncounterDamage: 0,
|
||||
aggregateEncounterDamage: 0,
|
||||
singleTargetDps: 0,
|
||||
aggregateDps: 0
|
||||
aggregateDps: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateSkillDps(input: SkillCalculationInput): SkillCalculation {
|
||||
export function calculateSkillDps(
|
||||
input: SkillCalculationInput,
|
||||
): SkillCalculation {
|
||||
const missing = requiredData(input);
|
||||
if (missing.length) {
|
||||
return {
|
||||
@@ -463,14 +546,15 @@ export function calculateSkillDps(input: SkillCalculationInput): SkillCalculatio
|
||||
revisionId: input.rules?.revisionId ?? null,
|
||||
missing,
|
||||
abilities: [],
|
||||
combined: null
|
||||
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) => {
|
||||
const abilities: SkillAbilityCalculation[] = input.abilities.map(
|
||||
(ability) => {
|
||||
if (ability.activation.mode === 'off') {
|
||||
return {
|
||||
abilityId: ability.id,
|
||||
@@ -479,26 +563,43 @@ export function calculateSkillDps(input: SkillCalculationInput): SkillCalculatio
|
||||
activationCount: 0,
|
||||
damageEventCount: 0,
|
||||
effectiveTargets: 0,
|
||||
damage: zeroDamage(Number(ability.baseDamage ?? 0))
|
||||
damage: zeroDamage(Number(ability.baseDamage ?? 0)),
|
||||
};
|
||||
}
|
||||
|
||||
const activations = skillActivationTimes(ability, encounterSeconds, input, rules);
|
||||
const events = damageEvents(ability, activations, encounterSeconds, input, rules);
|
||||
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
|
||||
)
|
||||
rules,
|
||||
),
|
||||
);
|
||||
const singleTargetEncounterDamage = damageByEvent.reduce(
|
||||
(total, damage) => total + damage,
|
||||
0,
|
||||
);
|
||||
const singleTargetEncounterDamage = damageByEvent.reduce((total, damage) => total + damage, 0);
|
||||
const effectiveTargets = Math.min(
|
||||
Number(ability.targetCount ?? targetCount),
|
||||
Number(ability.targetCap)
|
||||
Number(ability.targetCap),
|
||||
);
|
||||
const aggregateEncounterDamage = singleTargetEncounterDamage * effectiveTargets;
|
||||
const deliveredActivations = new Set(events.map((event) => event.activationIndex)).size;
|
||||
const aggregateEncounterDamage =
|
||||
singleTargetEncounterDamage * effectiveTargets;
|
||||
const deliveredActivations = new Set(
|
||||
events.map((event) => event.activationIndex),
|
||||
).size;
|
||||
const averageExpectedDamagePerHit = damageByEvent.length
|
||||
? singleTargetEncounterDamage / damageByEvent.length
|
||||
: 0;
|
||||
@@ -517,30 +618,34 @@ export function calculateSkillDps(input: SkillCalculationInput): SkillCalculatio
|
||||
unmodifiedBaseDamage: Number(ability.baseDamage),
|
||||
averageExpectedDamagePerHit,
|
||||
singleTargetDamagePerActivation,
|
||||
aggregateDamagePerActivation: singleTargetDamagePerActivation * effectiveTargets,
|
||||
aggregateDamagePerActivation:
|
||||
singleTargetDamagePerActivation * effectiveTargets,
|
||||
singleTargetEncounterDamage,
|
||||
aggregateEncounterDamage,
|
||||
singleTargetDps: singleTargetEncounterDamage / encounterSeconds,
|
||||
aggregateDps: aggregateEncounterDamage / encounterSeconds
|
||||
}
|
||||
aggregateDps: aggregateEncounterDamage / encounterSeconds,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const combined = abilities.reduce<SkillCombinedCalculation>(
|
||||
(total, ability) => ({
|
||||
singleTargetEncounterDamage:
|
||||
total.singleTargetEncounterDamage + ability.damage.singleTargetEncounterDamage,
|
||||
total.singleTargetEncounterDamage +
|
||||
ability.damage.singleTargetEncounterDamage,
|
||||
aggregateEncounterDamage:
|
||||
total.aggregateEncounterDamage + ability.damage.aggregateEncounterDamage,
|
||||
total.aggregateEncounterDamage +
|
||||
ability.damage.aggregateEncounterDamage,
|
||||
singleTargetDps: total.singleTargetDps + ability.damage.singleTargetDps,
|
||||
aggregateDps: total.aggregateDps + ability.damage.aggregateDps
|
||||
aggregateDps: total.aggregateDps + ability.damage.aggregateDps,
|
||||
}),
|
||||
{
|
||||
singleTargetEncounterDamage: 0,
|
||||
aggregateEncounterDamage: 0,
|
||||
singleTargetDps: 0,
|
||||
aggregateDps: 0
|
||||
}
|
||||
aggregateDps: 0,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -548,6 +653,6 @@ export function calculateSkillDps(input: SkillCalculationInput): SkillCalculatio
|
||||
revisionId: rules.revisionId,
|
||||
missing: [],
|
||||
abilities,
|
||||
combined
|
||||
combined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,26 +1,35 @@
|
||||
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 => ({
|
||||
const draft = (
|
||||
outcomes: ConditionalMechanicDraftV1['clauses'][number]['outcomes'],
|
||||
): ConditionalMechanicDraftV1 => ({
|
||||
version: 1,
|
||||
simulationReadiness: 'ready',
|
||||
scenarioInputs: [],
|
||||
pools: [],
|
||||
clauses: [{
|
||||
clauses: [
|
||||
{
|
||||
key: 'primary',
|
||||
label: 'Primary',
|
||||
event: 'enemy_killed',
|
||||
if: { kind: 'true' },
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [],
|
||||
outcomes
|
||||
}]
|
||||
outcomes,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
describe('talent description outcomes', () => {
|
||||
it('accepts a numbered restoration outcome as a description value', () => {
|
||||
const outcomes = numberedTalentOutcomes(draft([{
|
||||
const outcomes = numberedTalentOutcomes(
|
||||
draft([
|
||||
{
|
||||
key: 'restore-health',
|
||||
label: 'Restore Health',
|
||||
phase: 'after_event',
|
||||
@@ -33,17 +42,26 @@ describe('talent description outcomes', () => {
|
||||
descriptionSlot: 1,
|
||||
variants: {
|
||||
I: { value: 240 },
|
||||
II: { value: 336 }
|
||||
}
|
||||
}]));
|
||||
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[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([{
|
||||
const outcomes = numberedTalentOutcomes(
|
||||
draft([
|
||||
{
|
||||
key: 'delayed-reload',
|
||||
label: 'Delayed reload',
|
||||
phase: 'after_event',
|
||||
@@ -53,17 +71,23 @@ describe('talent description outcomes', () => {
|
||||
descriptionSlot: 1,
|
||||
variants: {
|
||||
I: { value: 4 },
|
||||
II: { value: 3 }
|
||||
}
|
||||
}]));
|
||||
II: { value: 3 },
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
expect(outcomes).toHaveLength(1);
|
||||
expect(outcomes[0]).toMatchObject({ kind: 'marker', descriptionSlot: 1 });
|
||||
expect(missingTalentDescriptionSlots('Reload after {1} seconds.', outcomes)).toEqual([]);
|
||||
expect(
|
||||
missingTalentDescriptionSlots('Reload after {1} seconds.', outcomes),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('accepts a numbered weapon-reload delay', () => {
|
||||
const outcomes = numberedTalentOutcomes(draft([{
|
||||
const outcomes = numberedTalentOutcomes(
|
||||
draft([
|
||||
{
|
||||
key: 'inactive-reload',
|
||||
label: 'Inactive weapon reload',
|
||||
phase: 'after_event',
|
||||
@@ -73,17 +97,26 @@ describe('talent description outcomes', () => {
|
||||
descriptionSlot: 1,
|
||||
variants: {
|
||||
I: { value: 4 },
|
||||
II: { value: 3 }
|
||||
}
|
||||
}]));
|
||||
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[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([{
|
||||
const outcomes = numberedTalentOutcomes(
|
||||
draft([
|
||||
{
|
||||
key: 'damage',
|
||||
label: 'Damage',
|
||||
phase: 'on_event',
|
||||
@@ -93,10 +126,14 @@ describe('talent description outcomes', () => {
|
||||
descriptionSlot: 2,
|
||||
variants: {
|
||||
I: { value: 10 },
|
||||
II: { value: 14 }
|
||||
}
|
||||
}]));
|
||||
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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type {
|
||||
ConditionalMechanicDraftV1,
|
||||
ConditionalMechanicOutcomeDraft
|
||||
ConditionalMechanicOutcomeDraft,
|
||||
} from '$lib/calculations/mechanic-editor';
|
||||
|
||||
const VALUE_BEARING_OUTCOME_KINDS = new Set([
|
||||
@@ -9,32 +9,44 @@ const VALUE_BEARING_OUTCOME_KINDS = new Set([
|
||||
'restoration',
|
||||
'resource_cost',
|
||||
'weapon_reload',
|
||||
'marker'
|
||||
'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
|
||||
.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));
|
||||
.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[]
|
||||
outcomes: ConditionalMechanicOutcomeDraft[],
|
||||
) => {
|
||||
const availableSlots = new Set(outcomes.map((outcome) => Number(outcome.descriptionSlot)));
|
||||
return [...new Set(
|
||||
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))
|
||||
)];
|
||||
.filter((slot) => !availableSlots.has(slot)),
|
||||
),
|
||||
];
|
||||
};
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
let { article, kind = 'guide', prominent = false } = $props();
|
||||
</script>
|
||||
<article class="article-card" class:prominent>
|
||||
<a class="article-card-cover" href={`/${kind === 'guide' ? 'guides' : 'news'}/${encodeURIComponent(article.slug)}`}>
|
||||
{#if article.coverUrl}<img src={article.coverUrl} alt=""/>{:else}<div><span>SHD // {kind === 'guide' ? 'FIELD MANUAL' : 'TRANSMISSION'}</span><b>{article.tags?.[0]?.name ?? article.channel ?? 'COMMUNITY INTELLIGENCE'}</b></div>{/if}
|
||||
</a>
|
||||
<div class="article-card-body">
|
||||
<header><span>{article.channel ?? article.tags?.[0]?.name ?? kind.toUpperCase()}</span>{#if article.recommended}<b>RECOMMENDED</b>{/if}{#if article.featured}<b>FEATURED</b>{/if}</header>
|
||||
<h2><a href={`/${kind === 'guide' ? 'guides' : 'news'}/${encodeURIComponent(article.slug)}`}>{article.title}</a></h2>
|
||||
<p>{article.deck}</p>
|
||||
<footer><span>BY {(article.source?.name ?? article.author).toUpperCase()} · {new Date(article.publishedAt).toLocaleDateString()}</span>{#if kind === 'guide'}<b>{article.votes ? `★ ${article.rating.toFixed(1)} · ${article.votes}` : 'UNRATED'}</b>{/if}</footer>
|
||||
</div>
|
||||
</article>
|
||||
<style>
|
||||
.article-card{min-width:0;display:grid;grid-template-rows:220px 1fr;border:1px solid var(--shd-border);background:var(--shd-surface)}.article-card-cover{min-height:0;display:block;overflow:hidden;border-bottom:1px solid var(--shd-border);background:#090b0c}.article-card-cover img{width:100%;height:100%;display:block;object-fit:cover;filter:saturate(.78) contrast(1.06)}.article-card-cover>div{height:100%;display:grid;align-content:space-between;padding:16px;background:linear-gradient(145deg,#15191a 0 58%,#0d1011 58%);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.article-card-cover>div b{color:var(--shd-orange);font-size:1rem}.article-card-body{display:grid;grid-template-rows:auto auto 1fr auto;gap:9px;padding:16px}.article-card-body header{position:static;z-index:auto;width:auto;height:auto;min-height:0;padding:0;border:0;background:transparent}.article-card-body header,.article-card-body footer{display:flex;align-items:center;justify-content:space-between;gap:8px}.article-card-body header span,.article-card-body header b,.article-card-body footer{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.65px}.article-card-body header span,.article-card-body footer b{color:var(--shd-orange)}.article-card-body header b,.article-card-body footer span{color:var(--shd-text-muted)}.article-card-body h2,.article-card-body p{margin:0}.article-card-body h2{font-size:clamp(1.35rem,2.5vw,2rem);line-height:1.02}.article-card-body h2 a{color:var(--shd-text)}.article-card-body p{color:var(--shd-text-muted);font-family:var(--shd-font-editorial);line-height:1.5}.article-card-body footer{padding-top:10px;border-top:1px solid var(--shd-border)}.article-card:hover,.article-card:focus-within{border-color:var(--shd-border-strong);background:var(--shd-surface-active)}.article-card:hover h2 a,.article-card h2 a:focus-visible{color:var(--shd-orange)}.article-card.prominent{grid-template-columns:minmax(280px,.8fr) minmax(0,1.2fr);grid-template-rows:minmax(310px,auto)}.article-card.prominent .article-card-cover{border-right:1px solid var(--shd-border);border-bottom:0}.article-card.prominent .article-card-body{padding:clamp(20px,4vw,48px)}.article-card.prominent h2{font-size:clamp(2.2rem,5vw,4.5rem)}.article-card.prominent p{font-size:1.1rem}@media(max-width:720px){.article-card.prominent{grid-template-columns:1fr;grid-template-rows:220px auto}.article-card.prominent .article-card-cover{border-right:0;border-bottom:1px solid var(--shd-border)}}
|
||||
</style>
|
||||
@@ -0,0 +1,143 @@
|
||||
<script lang="ts">
|
||||
import ArticleEditor from '$lib/components/ArticleEditor.svelte';
|
||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||
import ModerationDisclosure from '$lib/components/ModerationDisclosure.svelte';
|
||||
import { toast } from '$lib/toast';
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
let { data, form = null } = $props();
|
||||
type ComposerTag = { id: string; name: string; group: string };
|
||||
let title = $state(untrack(() => data.draft.title));
|
||||
let deck = $state(untrack(() => data.draft.deck));
|
||||
let document = $state(untrack(() => data.draft.document));
|
||||
let selectedTags = $state<string[]>(untrack(() => [...data.draft.tagIds]));
|
||||
let channel = $state(untrack(() => data.draft.channel || data.newsChannels?.[0] || ''));
|
||||
let visibility = $state(untrack(() => data.article.visibility));
|
||||
let coverImageId = $state(untrack(() => data.draft.coverImageId || ''));
|
||||
let coverUrl = $state(untrack(() => data.draft.coverUrl || ''));
|
||||
let coverAlt = $state(untrack(() => data.draft.coverAlt || ''));
|
||||
let coverCaption = $state(untrack(() => data.draft.coverCaption || ''));
|
||||
let version = $state(untrack(() => data.draft.version));
|
||||
let saveState = $state<'saved' | 'saving' | 'error' | 'conflict'>('saved');
|
||||
let coverUploading = $state(false);
|
||||
let initialized = $state(false);
|
||||
let lastContent = '';
|
||||
let saveSequence = 0;
|
||||
const groupedTags = $derived.by(() => {
|
||||
const groups: Record<string, ComposerTag[]> = {};
|
||||
for (const tag of data.tags as ComposerTag[]) (groups[tag.group] ??= []).push(tag);
|
||||
return Object.entries(groups);
|
||||
});
|
||||
|
||||
function payload() {
|
||||
return { articleId: data.article.id, title, deck, document, tagIds: selectedTags, channel,
|
||||
coverImageId, coverAlt, coverCaption, version };
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const content = JSON.stringify({ title, deck, document, selectedTags, channel, coverImageId, coverAlt, coverCaption });
|
||||
if (!initialized) {
|
||||
initialized = true;
|
||||
lastContent = content;
|
||||
return;
|
||||
}
|
||||
if (content === lastContent) return;
|
||||
lastContent = content;
|
||||
const sequence = ++saveSequence;
|
||||
saveState = 'saving';
|
||||
const timer = window.setTimeout(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/articles/draft', {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload())
|
||||
});
|
||||
const result = await response.json();
|
||||
if (sequence !== saveSequence) return;
|
||||
if (response.status === 409) {
|
||||
saveState = 'conflict';
|
||||
return;
|
||||
}
|
||||
if (!response.ok) throw new Error(result.message || 'Draft save failed.');
|
||||
version = result.version;
|
||||
saveState = 'saved';
|
||||
} catch (error) {
|
||||
if (sequence !== saveSequence) return;
|
||||
saveState = 'error';
|
||||
toast.error('Draft not saved', error instanceof Error ? error.message : 'Try again.');
|
||||
}
|
||||
}, 900);
|
||||
return () => window.clearTimeout(timer);
|
||||
});
|
||||
|
||||
function toggleTag(id: string) {
|
||||
selectedTags = selectedTags.includes(id) ? selectedTags.filter((tag) => tag !== id) : [...selectedTags, id].slice(0, 12);
|
||||
}
|
||||
|
||||
async function uploadCover(event: Event) {
|
||||
const file = (event.currentTarget as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
coverUploading = true;
|
||||
const body = new FormData();
|
||||
body.set('articleId', data.article.id);
|
||||
body.set('image', file);
|
||||
try {
|
||||
const response = await fetch('/api/articles/media', { method: 'POST', body });
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw new Error(result.message || 'Cover upload failed.');
|
||||
coverImageId = result.id;
|
||||
coverUrl = `/images/library/${result.id}`;
|
||||
toast.success('Cover uploaded', 'Add alternative text before publishing.');
|
||||
} catch (error) {
|
||||
toast.error('Cover not uploaded', error instanceof Error ? error.message : 'Try another image.');
|
||||
} finally {
|
||||
coverUploading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="app-page article-compose-page">
|
||||
<header class="article-compose-command">
|
||||
<div><p>{data.article.kind === 'guide' ? 'GUIDE COMPOSER' : 'NEWSROOM'}</p><h1>{data.article.kind === 'guide' ? 'AUTHOR FIELD INTELLIGENCE' : 'PREPARE A TRANSMISSION'}</h1></div>
|
||||
<div class="article-save-state" class:error={saveState === 'error' || saveState === 'conflict'}><i></i><span>{saveState === 'saving' ? 'SAVING DRAFT' : saveState === 'saved' ? 'DRAFT SAVED' : saveState === 'conflict' ? 'DRAFT CONFLICT · RELOAD' : 'SAVE FAILED'}</span></div>
|
||||
</header>
|
||||
{#if form?.articleError}<p class="error" role="alert">{form.articleError}</p>{/if}
|
||||
{#if form?.articleMessage}<p class="success" role="status">{form.articleMessage}</p>{/if}
|
||||
|
||||
<section class="article-compose-lede" aria-label="Article headline">
|
||||
<label class="article-title-field"><span>TITLE</span><textarea bind:value={title} maxlength="140" rows="1" placeholder="Give readers a clear reason to open this article"></textarea></label>
|
||||
<label class="article-deck-field"><span>SUMMARY</span><textarea bind:value={deck} maxlength="320" rows="2" placeholder="Describe what the reader will learn."></textarea></label>
|
||||
</section>
|
||||
|
||||
<section class="article-publication-dock" aria-label="Publication controls">
|
||||
<div class="article-publication-ident"><span>PUBLICATION</span><strong>{data.article.kind.toUpperCase()}</strong></div>
|
||||
<SegmentedChoice legend="VISIBILITY" name="composer-visibility" options={[{value:'private',label:'PRIVATE'},{value:'unlisted',label:'UNLISTED'},{value:'public',label:'PUBLIC'}]} value={visibility} onchange={(values) => visibility = values[0]}/>
|
||||
{#if data.article.kind === 'news'}<label class="article-channel-field">NEWS CHANNEL<select bind:value={channel}>{#each data.newsChannels as item}<option>{item}</option>{/each}</select></label>{/if}
|
||||
<div class="article-publication-note"><p>{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.'}</p>{#if data.article.pendingRevisionId}<strong>MODERATION REVIEW PENDING</strong>{/if}</div>
|
||||
<PendingForm action="?/publish" class="article-publish-form">
|
||||
<input type="hidden" name="articleId" value={data.article.id}/><input type="hidden" name="title" value={title}/><input type="hidden" name="deck" value={deck}/><input type="hidden" name="document" value={document}/><input type="hidden" name="tagIds" value={JSON.stringify(selectedTags)}/><input type="hidden" name="channel" value={channel}/><input type="hidden" name="coverImageId" value={coverImageId}/><input type="hidden" name="coverAlt" value={coverAlt}/><input type="hidden" name="coverCaption" value={coverCaption}/><input type="hidden" name="version" value={version}/><input type="hidden" name="visibility" value={visibility}/>
|
||||
<button class="primary">{visibility === 'private' ? 'SAVE AS PRIVATE' : data.article.kind === 'guide' ? 'SUBMIT FOR PUBLICATION' : 'PUBLISH ARTICLE'}</button>
|
||||
</PendingForm>
|
||||
</section>
|
||||
|
||||
<div class="article-compose-grid">
|
||||
<section class="article-compose-main">
|
||||
<ArticleEditor bind:value={document} articleId={data.article.id} builds={data.builds}/>
|
||||
</section>
|
||||
<aside class="article-compose-rail">
|
||||
<section><header><span>COVER IMAGE</span></header>{#if coverUrl}<img class="article-cover-preview" src={coverUrl} alt="Selected article cover"/>{/if}<label class="article-file-action">{coverUploading ? 'UPLOADING…' : 'CHOOSE COVER'}<input type="file" accept="image/jpeg,image/png,image/webp,image/avif,image/svg+xml" disabled={coverUploading} onchange={uploadCover}/></label><label>ALTERNATIVE TEXT<input bind:value={coverAlt} maxlength="300"/></label><label>CAPTION · OPTIONAL<input bind:value={coverCaption} maxlength="500"/></label></section>
|
||||
{#if data.article.kind === 'guide'}<section><header><span>DISCOVERY TAGS</span><b>{selectedTags.length}/12</b></header>{#each groupedTags as [group,tags]}<fieldset class="article-tag-group"><legend>{String(group).replace('_',' ').toUpperCase()}</legend><div>{#each tags as tag}<button type="button" class:selected={selectedTags.includes(tag.id)} aria-pressed={selectedTags.includes(tag.id)} onclick={() => toggleTag(tag.id)}>{tag.name}</button>{/each}</div></fieldset>{/each}</section>{/if}
|
||||
{#if data.article.kind === 'guide'}<ModerationDisclosure/>{/if}
|
||||
<PendingForm action="?/delete" class="article-delete-form"><input type="hidden" name="articleId" value={data.article.id}/><button class="danger" onclick={(event) => { if (!window.confirm('Delete this article? Its public page will be removed.')) event.preventDefault(); }}>DELETE ARTICLE</button></PendingForm>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.article-compose-page{padding-top:18px}.article-compose-command{position:static;width:auto;height:auto;display:flex;align-items:end;justify-content:space-between;gap:20px;padding:12px 0 16px;border-bottom:1px solid var(--shd-border);background:transparent}.article-compose-command p,.article-compose-command h1{margin:0}.article-compose-command p{color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1px}.article-compose-command h1{font-size:clamp(1.6rem,4vw,3rem)}.article-save-state{display:flex;align-items:center;gap:7px;color:var(--shd-success);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.article-save-state i{width:7px;height:7px;background:currentColor}.article-save-state.error{color:var(--shd-danger)}
|
||||
.article-compose-lede{display:grid;gap:10px;padding:22px 0}.article-title-field,.article-deck-field{display:grid;gap:5px}.article-title-field span,.article-deck-field span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.article-title-field textarea,.article-deck-field textarea{field-sizing:content;overflow:hidden;resize:none}.article-title-field textarea{min-height:1.9em;padding:14px 0;border:0;border-bottom:1px solid var(--shd-border-strong);background:transparent;color:var(--shd-text);font-family:var(--shd-font);font-size:clamp(2.6rem,6vw,5.5rem);font-weight:700;line-height:.92;letter-spacing:-.035em}.article-deck-field textarea{min-height:3.2em;max-width:76ch;padding:8px 0;border:0;background:transparent;color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:clamp(1.08rem,2vw,1.3rem);line-height:1.5}
|
||||
.article-publication-dock{display:grid;grid-template-columns:auto minmax(280px,340px) minmax(190px,240px) minmax(180px,1fr) auto;align-items:end;gap:12px;padding:12px;border:1px solid var(--shd-border-strong);background:var(--shd-surface)}.article-publication-ident{align-self:center;display:grid;gap:3px;padding-right:12px;border-right:1px solid var(--shd-border)}.article-publication-ident span,.article-publication-ident strong,.article-channel-field,.article-publication-note{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.article-publication-ident span,.article-channel-field,.article-publication-note p{color:var(--shd-text-muted)}.article-publication-ident strong{color:var(--shd-text)}.article-channel-field{display:grid;gap:5px}.article-publication-note{align-self:center;display:grid;gap:3px}.article-publication-note p{margin:0;line-height:1.4}.article-publication-note strong{color:var(--shd-orange)}.article-publication-dock :global(.article-publish-form button){min-width:176px;min-height:44px}
|
||||
.article-compose-grid{display:grid;grid-template-columns:minmax(0,1fr) 310px;align-items:start;gap:12px;margin-top:12px}.article-compose-main{min-width:0}.article-compose-rail{position:sticky;top:86px;display:grid;gap:8px}.article-compose-rail>section,.article-compose-rail :global(.moderation-disclosure){display:grid;gap:9px;padding:10px;border:1px solid var(--shd-border);background:var(--shd-surface)}.article-compose-rail section>header{position:static;z-index:auto;width:auto;height:auto;min-height:0;display:flex;justify-content:space-between;gap:8px;padding:0 0 7px;border-bottom:1px solid var(--shd-border);background:transparent}.article-compose-rail section>header span,.article-compose-rail section>header b,.article-compose-rail label{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.article-compose-rail section>header span{color:var(--shd-text-muted)}.article-compose-rail label{display:grid;gap:5px;color:var(--shd-text-muted)}.article-cover-preview{width:100%;max-height:180px;object-fit:cover}.article-file-action{min-height:40px;display:flex!important;align-items:center;justify-content:center;border:1px solid var(--shd-border-strong);color:var(--shd-text)!important;cursor:pointer}.article-file-action input{position:absolute;opacity:0;pointer-events:none}.article-tag-group{display:grid;gap:5px;margin:0;padding:0;border:0}.article-tag-group legend{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:900}.article-tag-group>div{display:flex;flex-wrap:wrap;gap:4px}.article-tag-group button{min-height:32px;padding:0 7px;border:1px solid var(--shd-border);background:transparent;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900}.article-tag-group button.selected{border-color:var(--shd-orange);background:#211a0e;color:var(--shd-orange)}.article-compose-rail :global(.article-delete-form button){width:100%;min-height:44px;border:1px solid #6f3b34;background:#1b1110;color:var(--shd-danger);font-weight:900}.article-compose-rail :global(.article-delete-form button:hover),.article-compose-rail :global(.article-delete-form button:focus-visible){border-color:var(--shd-danger);background:#2a1714;color:#f4a18e}
|
||||
@media(max-width:1180px){.article-publication-dock{grid-template-columns:auto minmax(280px,1fr) minmax(200px,1fr) auto}.article-publication-note{grid-column:2/4}.article-compose-grid{grid-template-columns:minmax(0,1fr) 290px}}
|
||||
@media(max-width:900px){.article-publication-dock{grid-template-columns:1fr 1fr}.article-publication-ident{padding:0 0 8px;border-right:0;border-bottom:1px solid var(--shd-border)}.article-publication-note{grid-column:1/-1}.article-publication-dock :global(.article-publish-form),.article-publication-dock :global(.article-publish-form button){width:100%}.article-compose-grid{grid-template-columns:1fr}.article-compose-rail{position:static;grid-template-columns:repeat(2,minmax(0,1fr))}.article-compose-rail :global(.article-delete-form),.article-compose-rail :global(.moderation-disclosure){grid-column:1/-1}}
|
||||
@media(max-width:650px){.article-compose-command{align-items:start;flex-direction:column}.article-compose-lede{padding:18px 0}.article-title-field textarea{font-size:clamp(2.25rem,12vw,3.5rem)}.article-publication-dock,.article-compose-rail{grid-template-columns:1fr}.article-publication-ident,.article-publication-note,.article-publication-dock :global(.article-publish-form),.article-compose-rail :global(.article-delete-form),.article-compose-rail :global(.moderation-disclosure){grid-column:1}.article-publication-dock :global(.article-publish-form button){width:100%}}
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
import type { ArticleMark, ArticleNode } from '$lib/articles/document';
|
||||
import BuildEmbed from '$lib/components/BuildEmbed.svelte';
|
||||
|
||||
let {
|
||||
document,
|
||||
builds = {},
|
||||
images = {}
|
||||
}: {
|
||||
document: ArticleNode;
|
||||
builds?: Record<string, {
|
||||
buildId: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
author: string;
|
||||
ownerShared: boolean;
|
||||
available: boolean;
|
||||
weapons: Array<{ name: string; category: string }>;
|
||||
gear: Array<{ name: string; slot: string }>;
|
||||
}>;
|
||||
images?: Record<string, { url: string; alt: string; caption: string }>;
|
||||
} = $props();
|
||||
|
||||
function link(mark: ArticleMark) {
|
||||
return mark.type === 'link' ? mark.attrs?.href ?? '#' : '#';
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet renderText(text: string, marks: ArticleMark[], index = 0)}
|
||||
{#if index >= marks.length}{text}{:else}
|
||||
{@const mark = marks[index]}
|
||||
{#if mark.type === 'bold'}<strong>{@render renderText(text, marks, index + 1)}</strong>
|
||||
{:else if mark.type === 'italic'}<em>{@render renderText(text, marks, index + 1)}</em>
|
||||
{:else if mark.type === 'code'}<code>{@render renderText(text, marks, index + 1)}</code>
|
||||
{:else}<a href={link(mark)} rel="nofollow ugc noopener noreferrer">{@render renderText(text, marks, index + 1)}</a>{/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'}<p>{@render children(node)}</p>
|
||||
{:else if node.type === 'heading' && Number(node.attrs?.level) === 2}<h2>{@render children(node)}</h2>
|
||||
{:else if node.type === 'heading'}<h3>{@render children(node)}</h3>
|
||||
{:else if node.type === 'bulletList'}<ul>{@render children(node)}</ul>
|
||||
{:else if node.type === 'orderedList'}<ol>{@render children(node)}</ol>
|
||||
{:else if node.type === 'listItem'}<li>{@render children(node)}</li>
|
||||
{:else if node.type === 'blockquote'}<blockquote>{@render children(node)}</blockquote>
|
||||
{:else if node.type === 'horizontalRule'}<hr/>
|
||||
{:else if node.type === 'hardBreak'}<br/>
|
||||
{:else if node.type === 'buildEmbed' && builds[String(node.attrs?.embedKey ?? '')]}
|
||||
<BuildEmbed embed={builds[String(node.attrs?.embedKey ?? '')]}/>
|
||||
{:else if node.type === 'articleImage' && images[String(node.attrs?.imageId ?? '')]}
|
||||
{@const image = images[String(node.attrs?.imageId ?? '')]}
|
||||
<figure><img src={image.url} alt={image.alt}/>{#if image.caption}<figcaption>{image.caption}</figcaption>{/if}</figure>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<div class="article-content">{@render children(document)}</div>
|
||||
|
||||
<style>
|
||||
.article-content{max-width:76ch;color:var(--shd-text);font-family:var(--shd-font-editorial);font-size:clamp(1.02rem,1.4vw,1.15rem);line-height:1.72}.article-content :global(p){margin:0 0 1.25em}.article-content :global(h2),.article-content :global(h3){font-family:var(--shd-font);line-height:1.05}.article-content :global(h2){margin:2.1em 0 .6em;font-size:clamp(1.8rem,4vw,2.5rem)}.article-content :global(h3){margin:1.8em 0 .55em;font-size:1.4rem}.article-content :global(ul),.article-content :global(ol){display:grid;gap:7px;margin:0 0 1.35em;padding-left:1.4em}.article-content :global(blockquote){margin:2em 0;padding:18px 22px;border:1px solid var(--shd-border-strong);background:#0d1011;font-size:1.16em;font-style:italic}.article-content :global(hr){margin:38px 0;border:0;border-top:1px solid var(--shd-border-strong)}.article-content :global(a){color:var(--shd-orange);text-decoration:underline;text-underline-offset:3px}.article-content :global(code){padding:1px 4px;background:#080a0b;color:var(--shd-info);font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:.88em}.article-content :global(figure){margin:34px 0}.article-content :global(figure img){max-width:100%;height:auto;display:block}.article-content :global(figcaption){padding:9px 0;color:var(--shd-text-muted);font-family:var(--shd-font);font-size:var(--shd-type-supporting);line-height:1.45}
|
||||
</style>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import AgentIdentityLink from '$lib/components/AgentIdentityLink.svelte';
|
||||
import MarkdownContent from '$lib/components/MarkdownContent.svelte';
|
||||
import CommentComposer from '$lib/components/CommentComposer.svelte';
|
||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||
import ContentReport from '$lib/components/ContentReport.svelte';
|
||||
let { comments, viewer, form = null, signInHref } = $props();
|
||||
let replyingTo = $state('');
|
||||
</script>
|
||||
<section class="article-discussion" id="discussion" aria-labelledby="article-discussion-title">
|
||||
<header><div><span>COMMUNITY DISCUSSION</span><h2 id="article-discussion-title">FIELD NOTES & QUESTIONS</h2></div><b>{comments.filter((item:any)=>item.status==='visible').length} COMMENTS</b></header>
|
||||
{#if viewer}
|
||||
<PendingForm action="?/comment"><CommentComposer name="body" label="ADD TO THE DISCUSSION" required/>{#if form?.commentError}<p class="error">{form.commentError}</p>{/if}{#if form?.commentMessage}<p class="success">{form.commentMessage}</p>{/if}<button class="primary">POST COMMENT</button></PendingForm>
|
||||
{:else}<div class="discussion-sign-in"><p>Sign in to add a comment or reply.</p><a class="secondary" href={signInHref}>SIGN IN TO COMMENT</a></div>{/if}
|
||||
<div class="article-comment-list">
|
||||
{#each comments as comment}
|
||||
<article id={`comment-${comment.id}`} class:reply={comment.depth>0} style={`--depth:${comment.depth}`}>
|
||||
{#if comment.status === 'visible'}
|
||||
<header><AgentIdentityLink size="compact" linked={Boolean(comment.author.publicSlug)} agent={comment.author} meta={new Date(comment.createdAt).toLocaleString()}/></header>
|
||||
<MarkdownContent html={comment.bodyHtml} compact/>
|
||||
<div class="article-comment-actions">{#if viewer}<button type="button" onclick={()=>replyingTo=replyingTo===comment.id?'':comment.id}>REPLY</button>{/if}{#if comment.canReport}<ContentReport contentType="comment" contentId={comment.id} result={form} plain/>{/if}{#if comment.canDelete}<PendingForm action="?/removeComment"><input type="hidden" name="commentId" value={comment.id}/><button class="danger">REMOVE</button></PendingForm>{/if}</div>
|
||||
{#if replyingTo===comment.id}<PendingForm action="?/comment"><input type="hidden" name="parentId" value={comment.id}/><CommentComposer name="body" label="WRITE A REPLY" required rows={3}/><button class="primary">POST REPLY</button></PendingForm>{/if}
|
||||
{:else}<div class="removed-comment"><strong>COMMENT REMOVED</strong><span>Replies remain visible to preserve the conversation.</span></div>{/if}
|
||||
</article>
|
||||
{:else}<div class="discussion-empty"><strong>NO COMMENTS YET</strong><span>Start the discussion with a useful question or field observation.</span></div>{/each}
|
||||
</div>
|
||||
</section>
|
||||
<style>
|
||||
.article-discussion{display:grid;gap:18px;margin-top:54px;padding-top:24px;border-top:1px solid var(--shd-border)}.article-discussion header{position:static;z-index:auto;width:auto;height:auto;min-height:0;padding:0;border:0;background:transparent}.article-discussion>header{display:flex;align-items:end;justify-content:space-between;gap:16px}.article-discussion>header span,.article-discussion>header b{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.article-discussion>header h2{margin:4px 0 0;font-size:clamp(1.6rem,4vw,2.5rem)}.discussion-sign-in{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px;border:1px solid var(--shd-border)}.discussion-sign-in p{margin:0;color:var(--shd-text-muted)}.article-comment-list{max-width:980px;display:grid}.article-comment-list>article{display:grid;gap:8px;margin:20px 0 0 calc(min(var(--depth),4) * 24px);padding-left:calc(var(--depth) * 0px);scroll-margin-top:96px}.article-comment-list>article.reply{padding-left:14px;border-left:1px solid var(--shd-border-strong)}.article-comment-list article :global(.markdown-content),.article-comment-actions,.article-comment-list article :global(.comment-composer){margin-left:47px}.article-comment-actions{display:flex;align-items:center;gap:12px}.article-comment-actions>button,.article-comment-actions :global(button){min-height:32px;padding:0;border:0;background:transparent;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900}.article-comment-actions>button:hover,.article-comment-actions>button:focus-visible{color:var(--shd-orange)}.removed-comment{display:grid;gap:3px;padding:6px 0;color:var(--shd-text-dim)}.removed-comment strong{font-size:var(--shd-type-telemetry);letter-spacing:.7px}.discussion-empty{display:grid;gap:4px;padding:24px 0;color:var(--shd-text-muted)}.discussion-empty strong{color:var(--shd-text)}@media(max-width:650px){.article-discussion>header,.discussion-sign-in{align-items:start;flex-direction:column}.article-comment-list>article{margin-left:calc(min(var(--depth),3) * 10px)}.article-comment-list article :global(.markdown-content),.article-comment-actions,.article-comment-list article :global(.comment-composer){margin-left:0}}
|
||||
</style>
|
||||
@@ -0,0 +1,188 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import type { Editor, JSONContent } from '@tiptap/core';
|
||||
import { EMPTY_ARTICLE_DOCUMENT, ARTICLE_DOCUMENT_VERSION } from '$lib/articles/document';
|
||||
import { ArticleImageNode, BuildEmbedNode } from '$lib/articles/editor-extensions';
|
||||
|
||||
let {
|
||||
value = $bindable(JSON.stringify(EMPTY_ARTICLE_DOCUMENT)),
|
||||
articleId,
|
||||
builds = [],
|
||||
disabled = false
|
||||
}: {
|
||||
value?: string;
|
||||
articleId: string;
|
||||
builds?: Array<{ id: string; title: string; visibility: string; owner: boolean }>;
|
||||
disabled?: boolean;
|
||||
} = $props();
|
||||
|
||||
let holder: HTMLDivElement;
|
||||
let editor = $state<Editor>();
|
||||
let buildSearch = $state('');
|
||||
let showBuilds = $state(false);
|
||||
let uploadOpen = $state(false);
|
||||
let uploadError = $state('');
|
||||
let uploading = $state(false);
|
||||
let altText = $state('');
|
||||
let caption = $state('');
|
||||
let uploadFile = $state<File>();
|
||||
|
||||
const filteredBuilds = $derived(builds.filter((build) =>
|
||||
build.title.toLocaleLowerCase().includes(buildSearch.trim().toLocaleLowerCase())
|
||||
).slice(0, 30));
|
||||
|
||||
function envelope(): JSONContent {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (parsed?.version === ARTICLE_DOCUMENT_VERSION && parsed.document?.type === 'doc') return parsed.document;
|
||||
} catch {
|
||||
// The server remains authoritative; an invalid local draft starts empty.
|
||||
}
|
||||
return EMPTY_ARTICLE_DOCUMENT.document;
|
||||
}
|
||||
|
||||
function updateValue(instance: Editor) {
|
||||
value = JSON.stringify({ version: ARTICLE_DOCUMENT_VERSION, document: instance.getJSON() });
|
||||
}
|
||||
|
||||
function toggleLink() {
|
||||
if (!editor) return;
|
||||
if (editor.isActive('link')) {
|
||||
editor.chain().focus().unsetLink().run();
|
||||
return;
|
||||
}
|
||||
const href = window.prompt('Paste an HTTP or HTTPS link');
|
||||
if (href) editor.chain().focus().extendMarkRange('link').setLink({ href }).run();
|
||||
}
|
||||
|
||||
function insertBuild(buildId: string) {
|
||||
if (!editor) return;
|
||||
const embedKey = `build-${crypto.randomUUID()}`;
|
||||
editor.chain().focus().insertContent({ type: 'buildEmbed', attrs: { buildId, embedKey } }).insertContent({ type: 'paragraph' }).run();
|
||||
showBuilds = false;
|
||||
buildSearch = '';
|
||||
}
|
||||
|
||||
async function uploadImage() {
|
||||
if (!uploadFile || !altText.trim() || !editor) {
|
||||
uploadError = 'Choose an image and provide descriptive alternative text.';
|
||||
return;
|
||||
}
|
||||
uploading = true;
|
||||
uploadError = '';
|
||||
const form = new FormData();
|
||||
form.set('articleId', articleId);
|
||||
form.set('image', uploadFile);
|
||||
try {
|
||||
const response = await fetch('/api/articles/media', { method: 'POST', body: form });
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw new Error(result.message || 'Image upload failed.');
|
||||
editor.chain().focus().insertContent({
|
||||
type: 'articleImage',
|
||||
attrs: { imageId: result.id, alt: altText.trim(), caption: caption.trim() }
|
||||
}).insertContent({ type: 'paragraph' }).run();
|
||||
uploadFile = undefined;
|
||||
altText = '';
|
||||
caption = '';
|
||||
uploadOpen = false;
|
||||
} catch (error) {
|
||||
uploadError = error instanceof Error ? error.message : 'Image upload failed.';
|
||||
} finally {
|
||||
uploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
let disposed = false;
|
||||
void Promise.all([import('@tiptap/core'), import('@tiptap/starter-kit'), import('@tiptap/extensions')]).then(([core, starter, extensions]) => {
|
||||
if (disposed) return;
|
||||
editor = new core.Editor({
|
||||
element: holder,
|
||||
content: envelope(),
|
||||
editable: !disabled,
|
||||
enableContentCheck: true,
|
||||
injectCSS: false,
|
||||
extensions: [
|
||||
starter.StarterKit.configure({
|
||||
heading: { levels: [2, 3] },
|
||||
codeBlock: false,
|
||||
strike: false,
|
||||
underline: false,
|
||||
link: { openOnClick: false, autolink: true, protocols: ['http', 'https'] }
|
||||
}),
|
||||
BuildEmbedNode,
|
||||
ArticleImageNode,
|
||||
extensions.Placeholder.configure({
|
||||
placeholder: 'Start writing. Use the toolbar to add structure, images, or Builds.'
|
||||
})
|
||||
],
|
||||
editorProps: { attributes: { 'aria-label': 'Article body', 'aria-multiline': 'true' } },
|
||||
onUpdate: ({ editor: instance }) => updateValue(instance)
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
disposed = true;
|
||||
editor?.destroy();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="article-editor-shell" aria-label="Article editor">
|
||||
<div class="article-editor-toolbar" aria-label="Formatting controls">
|
||||
<button type="button" aria-label="Paragraph" class:active={editor?.isActive('paragraph')} onclick={() => editor?.chain().focus().setParagraph().run()}>TEXT</button>
|
||||
<button type="button" aria-label="Heading level 2" class:active={editor?.isActive('heading', { level: 2 })} onclick={() => editor?.chain().focus().toggleHeading({ level: 2 }).run()}>H2</button>
|
||||
<button type="button" aria-label="Heading level 3" class:active={editor?.isActive('heading', { level: 3 })} onclick={() => editor?.chain().focus().toggleHeading({ level: 3 }).run()}>H3</button>
|
||||
<button type="button" aria-label="Bold" class:active={editor?.isActive('bold')} onclick={() => editor?.chain().focus().toggleBold().run()}><strong>B</strong></button>
|
||||
<button type="button" aria-label="Italic" class:active={editor?.isActive('italic')} onclick={() => editor?.chain().focus().toggleItalic().run()}><em>I</em></button>
|
||||
<button type="button" aria-label="Inline code" class:active={editor?.isActive('code')} onclick={() => editor?.chain().focus().toggleCode().run()}>CODE</button>
|
||||
<button type="button" aria-label="Bulleted list" class:active={editor?.isActive('bulletList')} onclick={() => editor?.chain().focus().toggleBulletList().run()}>LIST</button>
|
||||
<button type="button" aria-label="Numbered list" class:active={editor?.isActive('orderedList')} onclick={() => editor?.chain().focus().toggleOrderedList().run()}>1.</button>
|
||||
<button type="button" aria-label="Quote" class:active={editor?.isActive('blockquote')} onclick={() => editor?.chain().focus().toggleBlockquote().run()}>QUOTE</button>
|
||||
<button type="button" aria-label="Link" class:active={editor?.isActive('link')} onclick={toggleLink}>LINK</button>
|
||||
<button type="button" aria-label="Divider" onclick={() => editor?.chain().focus().setHorizontalRule().run()}>RULE</button>
|
||||
<button type="button" aria-expanded={showBuilds} onclick={() => { showBuilds = !showBuilds; uploadOpen = false; }}>+ BUILD</button>
|
||||
<button type="button" aria-expanded={uploadOpen} onclick={() => { uploadOpen = !uploadOpen; showBuilds = false; }}>+ IMAGE</button>
|
||||
<span></span>
|
||||
<button type="button" aria-label="Undo" disabled={!editor?.can().undo()} onclick={() => editor?.chain().focus().undo().run()}>UNDO</button>
|
||||
<button type="button" aria-label="Redo" disabled={!editor?.can().redo()} onclick={() => editor?.chain().focus().redo().run()}>REDO</button>
|
||||
</div>
|
||||
{#if showBuilds}
|
||||
<div class="article-insert-panel">
|
||||
<label>FIND A BUILD<input bind:value={buildSearch} placeholder="Search your Builds and the public archive"/></label>
|
||||
<div class="article-build-results">
|
||||
{#each filteredBuilds as build}
|
||||
<button type="button" onclick={() => insertBuild(build.id)}><span>{build.owner ? 'YOUR BUILD' : 'PUBLIC BUILD'} · {build.visibility.toUpperCase()}</span><strong>{build.title}</strong><b>INSERT →</b></button>
|
||||
{:else}<p>No eligible Builds match this search.</p>{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if uploadOpen}
|
||||
<div class="article-insert-panel article-image-insert">
|
||||
<label>IMAGE<input type="file" accept="image/jpeg,image/png,image/webp,image/avif,image/svg+xml" onchange={(event) => uploadFile = (event.currentTarget as HTMLInputElement).files?.[0]}/></label>
|
||||
<label>ALTERNATIVE TEXT<input bind:value={altText} maxlength="300" placeholder="Describe the image for readers who cannot see it"/></label>
|
||||
<label>CAPTION · OPTIONAL<input bind:value={caption} maxlength="500"/></label>
|
||||
{#if uploadError}<p class="error" role="alert">{uploadError}</p>{/if}
|
||||
<button type="button" class="primary" disabled={uploading} onclick={uploadImage}>{uploading ? 'UPLOADING…' : 'INSERT IMAGE'}</button>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="article-editor-canvas" bind:this={holder}></div>
|
||||
<input type="hidden" name="document" {value}/>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.article-editor-shell{border:1px solid var(--shd-border-strong);background:var(--shd-surface)}
|
||||
.article-editor-toolbar{position:sticky;top:76px;z-index:4;display:flex;flex-wrap:wrap;border-bottom:1px solid var(--shd-border);background:#111415}
|
||||
.article-editor-toolbar button{min-width:42px;min-height:40px;padding:0 10px;border:0;border-right:1px solid var(--shd-border);background:transparent;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.5px;cursor:pointer}
|
||||
.article-editor-toolbar button:hover,.article-editor-toolbar button:focus-visible,.article-editor-toolbar button.active{background:var(--shd-surface-active);color:var(--shd-orange)}
|
||||
.article-editor-toolbar button:active{transform:translateY(1px)}.article-editor-toolbar button:disabled{opacity:.4;cursor:not-allowed}.article-editor-toolbar>span{flex:1}
|
||||
.article-editor-canvas{min-height:580px;padding:clamp(24px,5vw,68px)}
|
||||
.article-editor-canvas :global(.tiptap){max-width:76ch;min-height:500px;margin:0 auto;outline:0;color:var(--shd-text);font-family:var(--shd-font-editorial);font-size:1.08rem;line-height:1.72}
|
||||
.article-editor-canvas :global(.tiptap p){margin:0 0 1.2em}.article-editor-canvas :global(.tiptap h2){margin:2.2em 0 .6em;font-family:var(--shd-font);font-size:2rem;line-height:1.05}.article-editor-canvas :global(.tiptap h3){margin:1.8em 0 .55em;font-family:var(--shd-font);font-size:1.35rem}
|
||||
.article-editor-canvas :global(.tiptap p.is-editor-empty:first-child::before){height:0;float:left;color:var(--shd-text-dim);font-family:var(--shd-font-editorial);font-style:italic;content:attr(data-placeholder);pointer-events:none}
|
||||
.article-editor-canvas :global(.tiptap blockquote){margin:2em 0;padding:16px 20px;border:1px solid var(--shd-border-strong);background:#0d1011;font-size:1.15rem;font-style:italic}.article-editor-canvas :global(.tiptap hr){margin:32px 0;border:0;border-top:1px solid var(--shd-border-strong)}
|
||||
.article-editor-canvas :global(.article-editor-embed){display:grid;grid-template-columns:auto minmax(0,1fr);gap:8px;margin:24px 0;padding:18px;border:1px solid var(--shd-orange);background:#17140e;font-family:var(--shd-font);cursor:grab}.article-editor-canvas :global(.article-editor-embed span){color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900}.article-editor-canvas :global(.article-editor-embed strong){overflow:hidden;text-overflow:ellipsis}
|
||||
.article-editor-canvas :global(.article-editor-image){margin:28px 0}.article-editor-canvas :global(.article-editor-image img){max-width:100%;display:block}.article-editor-canvas :global(.article-editor-image figcaption){padding:8px 0;color:var(--shd-text-muted);font-family:var(--shd-font);font-size:var(--shd-type-supporting)}
|
||||
.article-insert-panel{display:grid;gap:10px;padding:12px;border-bottom:1px solid var(--shd-border);background:#0d1011}.article-insert-panel label{display:grid;gap:5px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.6px}.article-insert-panel input{width:100%}.article-build-results{max-height:280px;display:grid;overflow:auto;border:1px solid var(--shd-border)}.article-build-results button{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:3px 12px;padding:10px;border:0;border-top:1px solid var(--shd-border);background:var(--shd-surface);color:var(--shd-text);text-align:left}.article-build-results button:first-child{border-top:0}.article-build-results button:hover,.article-build-results button:focus-visible{background:var(--shd-surface-active)}.article-build-results span{grid-column:1/-1;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900}.article-build-results b{color:var(--shd-orange);font-size:var(--shd-type-telemetry)}.article-image-insert{grid-template-columns:repeat(3,minmax(0,1fr)) auto;align-items:end}.article-image-insert .error{grid-column:1/-1}
|
||||
@media(max-width:760px){.article-editor-toolbar{top:64px;flex-wrap:nowrap;overflow-x:auto;scrollbar-width:thin}.article-editor-toolbar>span{display:none}.article-editor-toolbar button{flex:none;min-width:46px;min-height:44px}.article-editor-canvas{min-height:440px;padding:24px 16px}.article-editor-canvas :global(.tiptap){min-height:390px}.article-image-insert{grid-template-columns:1fr}.article-insert-panel{padding:10px}}
|
||||
@media(prefers-reduced-motion:reduce){.article-editor-toolbar button{transition:none}}
|
||||
</style>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import ArticleContent from '$lib/components/ArticleContent.svelte';
|
||||
import ArticleDiscussion from '$lib/components/ArticleDiscussion.svelte';
|
||||
import AgentIdentityLink from '$lib/components/AgentIdentityLink.svelte';
|
||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||
import { untrack } from 'svelte';
|
||||
let { data, form = null } = $props();
|
||||
let reportOpen = $state(untrack(() => Boolean(form?.articleReportError)));
|
||||
const path = $derived(data.article.kind === 'guide' ? 'guides' : 'news');
|
||||
const publicationMeta = $derived(data.article.source
|
||||
? `PUBLISHED ${new Date(data.article.source.publishedAt).toLocaleDateString()} · VIA ${data.article.source.name.toUpperCase()}`
|
||||
: new Date(data.article.updatedAt).getTime() > new Date(data.article.publishedAt).getTime()
|
||||
? `UPDATED ${new Date(data.article.updatedAt).toLocaleDateString()}`
|
||||
: `PUBLISHED ${new Date(data.article.publishedAt).toLocaleDateString()}`);
|
||||
</script>
|
||||
<svelte:head>
|
||||
<title>{data.article.title} — SHD.build</title>
|
||||
<meta name="description" content={data.article.deck}/>
|
||||
<meta property="og:title" content={data.article.title}/><meta property="og:description" content={data.article.deck}/>
|
||||
{#if data.article.coverUrl}<meta property="og:image" content={data.article.coverUrl}/>{/if}
|
||||
{#if data.article.source}<link rel="canonical" href={data.article.source.url}/>{/if}
|
||||
{#if data.turnstileSiteKey && form?.articleChallengeRequired}<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>{/if}
|
||||
</svelte:head>
|
||||
<main class="page article-reader">
|
||||
<header class="article-reader-head">
|
||||
<div class="article-reader-channel"><span>{data.article.channel ?? (data.article.kind === 'guide' ? 'COMMUNITY GUIDE' : 'NEWS')}</span>{#if data.article.featured}<b>FEATURED</b>{/if}{#if data.article.recommended}<b>RECOMMENDED</b>{/if}</div>
|
||||
<h1>{data.article.title}</h1><p>{data.article.deck}</p>
|
||||
<div class="article-reader-byline">{#if data.article.source}<div class="article-source-identity"><strong>{data.article.source.name}</strong><span>{publicationMeta}</span></div>{:else}<AgentIdentityLink size="compact" linked={Boolean(data.article.authorPublicSlug)} agent={{id:data.article.ownerId,displayName:data.article.author,publicSlug:data.article.authorPublicSlug,avatarUrl:`/avatars/user/${data.article.ownerId}`}} meta={publicationMeta}/>{/if}<div>{#if data.article.kind === 'guide' && data.article.owner}<a class="secondary" href={`/guides/${encodeURIComponent(data.article.slug)}/edit`}>EDIT GUIDE</a>{:else if data.article.kind === 'news' && data.canManageNews && !data.article.source}<a class="secondary" href={`/news/${encodeURIComponent(data.article.slug)}/edit`}>OPEN NEWSROOM</a>{/if}</div></div>
|
||||
{#if data.article.source}<aside class="article-source-notice"><div><span>OFFICIAL SOURCE</span><strong>Originally published by {data.article.source.name}</strong></div><a href={data.article.source.url} target="_blank" rel="noopener noreferrer">READ ON UBISOFT</a></aside>{/if}
|
||||
{#if data.tags.length}<nav class="article-reader-tags" aria-label="Article tags">{#each data.tags as tag}<a href={`/${path}?tag=${encodeURIComponent(tag.slug)}`}>{tag.name}</a>{/each}</nav>{/if}
|
||||
</header>
|
||||
{#if data.article.coverUrl}<figure class="article-hero-image"><img src={data.article.coverUrl} alt={data.article.coverAlt}/>{#if data.article.coverCaption}<figcaption>{data.article.coverCaption}</figcaption>{/if}</figure>{/if}
|
||||
<div class="article-reader-layout" class:article-reader-layout-wide={data.article.kind === 'news'}>
|
||||
<article><ArticleContent document={data.document} builds={data.builds} images={data.images}/></article>
|
||||
{#if data.article.kind === 'guide'}<aside>
|
||||
<section class="guide-rating"><span>RATE THIS GUIDE</span><strong>{data.article.votes ? `${data.article.rating.toFixed(1)} / 5` : 'UNRATED'}</strong><small>{data.article.votes} RATING{data.article.votes===1?'':'S'}</small><PendingForm action="?/rate" class="rating-form"><div class="rating-stars">{#each [1,2,3,4,5] as value}<button name="value" {value} aria-label={`Rate ${value} out of 5`}>★<span>{value}</span></button>{/each}</div>{#if form?.ratingError}<p class="error">{form.ratingError}</p>{/if}{#if form?.articleChallengeRequired && data.turnstileSiteKey}<div class="cf-turnstile" data-sitekey={data.turnstileSiteKey}></div>{/if}</PendingForm></section>
|
||||
<section class="guide-report"><button type="button" aria-expanded={reportOpen} onclick={()=>reportOpen=!reportOpen}>REPORT GUIDE</button>{#if reportOpen}<PendingForm action="?/reportArticle"><label>CONCERN<select name="category" required><option value="">CHOOSE REASON</option><option value="spam">SPAM</option><option value="harassment">HARASSMENT</option><option value="hate">HATE</option><option value="sexual">SEXUAL CONTENT</option><option value="violence">VIOLENCE</option><option value="self_harm">SELF-HARM</option><option value="privacy">PRIVACY</option><option value="other">OTHER</option></select></label><label>DETAILS · OPTIONAL<textarea name="details" maxlength="500" rows="4"></textarea></label>{#if form?.articleReportError}<p class="error">{form.articleReportError}</p>{/if}{#if form?.articleChallengeRequired && data.turnstileSiteKey}<div class="cf-turnstile" data-sitekey={data.turnstileSiteKey}></div>{/if}<button class="danger">SEND REPORT</button></PendingForm>{/if}</section>
|
||||
{#if data.canCurate}<section class="guide-curation"><span>COMMUNITY OVERSIGHT</span>{#each [['pinned',data.article.pinned],['featured',data.article.featured],['recommended',data.article.recommended]] as item}<PendingForm action="?/curate"><input type="hidden" name="placement" value={item[0]}/><input type="hidden" name="enabled" value={item[1]?'0':'1'}/><button>{item[1]?'REMOVE ':''}{String(item[0]).toUpperCase()}</button></PendingForm>{/each}</section>{/if}
|
||||
</aside>{/if}
|
||||
</div>
|
||||
<ArticleDiscussion comments={data.comments} viewer={data.viewer} {form} signInHref={`/sign-in?returnTo=${encodeURIComponent(`/${path}/${data.article.slug}`)}`}/>
|
||||
</main>
|
||||
<style>
|
||||
.article-reader{padding-top:34px}.article-reader-head{position:static;z-index:auto;width:auto;height:auto;min-height:0;max-width:1100px;display:grid;gap:14px;margin:0 auto;padding:26px 0 34px;border:0;background:transparent;text-align:center}.article-reader-channel{display:flex;justify-content:center;gap:8px}.article-reader-channel span,.article-reader-channel b{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.9px}.article-reader-channel span{color:var(--shd-orange)}.article-reader-channel b{color:var(--shd-text-muted)}.article-reader-head h1,.article-reader-head>p{margin:0}.article-reader-head h1{font-size:clamp(3rem,7vw,6rem);line-height:.86;letter-spacing:-.035em;text-wrap:balance}.article-reader-head>p{max-width:68ch;justify-self:center;color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:clamp(1.1rem,2vw,1.35rem);line-height:1.5}.article-reader-byline{display:flex;align-items:center;justify-content:flex-start;gap:20px;text-align:left}.article-reader-byline>div{display:flex;gap:6px}.article-source-identity{display:grid!important;gap:2px!important}.article-source-identity strong{font-size:.95rem}.article-source-identity span,.article-source-notice span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.article-source-notice{display:flex;align-items:center;justify-content:space-between;gap:18px;padding:12px 14px;border:1px solid var(--shd-border-strong);background:var(--shd-surface);text-align:left}.article-source-notice>div{display:grid;gap:3px}.article-source-notice a{padding:9px 12px;border:1px solid var(--shd-orange);color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;white-space:nowrap}.article-source-notice a:hover,.article-source-notice a:focus-visible{background:var(--shd-orange);color:#090b0c}.article-reader-tags{display:flex;justify-content:center;flex-wrap:wrap;gap:5px}.article-reader-tags a{padding:5px 8px;border:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900}.article-reader-tags a:hover,.article-reader-tags a:focus-visible{border-color:var(--shd-orange);color:var(--shd-orange)}
|
||||
.article-hero-image{max-width:1380px;margin:0 auto 30px}.article-hero-image img{width:100%;max-height:660px;display:block;object-fit:cover}.article-hero-image figcaption{padding:8px 0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.article-reader-layout{display:grid;grid-template-columns:minmax(0,76ch) 250px;justify-content:center;align-items:start;gap:clamp(30px,6vw,90px)}.article-reader-layout-wide{grid-template-columns:minmax(0,1100px)}.article-reader-layout>aside{position:sticky;top:90px;display:grid;gap:8px}.article-reader-layout>aside>section{display:grid;gap:7px;padding:11px;border:1px solid var(--shd-border);background:var(--shd-surface)}.article-reader-layout>aside section>span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}
|
||||
.guide-rating>strong{font-size:1.5rem}.rating-stars{display:grid;grid-template-columns:repeat(5,1fr)}.rating-stars button{min-width:0;height:40px;padding:0;border:1px solid var(--shd-border);border-left:0;background:transparent;color:var(--shd-text-dim);font-size:18px}.rating-stars button:first-child{border-left:1px solid var(--shd-border)}.rating-stars button:hover,.rating-stars button:focus-visible{color:var(--shd-orange)}.rating-stars span{display:block;font-size:8px}.guide-report>button{min-height:38px;border:0;background:transparent;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;text-align:left}.guide-report :global(form){display:grid;gap:8px}.guide-report label{display:grid;gap:4px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900}.guide-curation :global(form button){width:100%;min-height:36px}
|
||||
@media(max-width:900px){.article-reader-layout{grid-template-columns:1fr}.article-reader-layout>article{display:grid;justify-content:center}.article-reader-layout>aside{position:static;grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:650px){.article-reader-head{text-align:left}.article-reader-channel,.article-reader-tags{justify-content:flex-start}.article-reader-byline{align-items:flex-start;flex-direction:column}.article-source-notice{align-items:stretch;flex-direction:column}.article-source-notice a{text-align:center}.article-reader-head h1{font-size:3.4rem}.article-reader-layout>aside{grid-template-columns:1fr}}
|
||||
</style>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
embed
|
||||
}: {
|
||||
embed: {
|
||||
buildId: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
author: string;
|
||||
ownerShared: boolean;
|
||||
available: boolean;
|
||||
weapons: Array<{ name: string; category: string }>;
|
||||
gear: Array<{ name: string; slot: string }>;
|
||||
};
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<aside class="build-embed" aria-label={`Embedded Build: ${embed.title}`}>
|
||||
<header>
|
||||
<div><span>SHD BUILD EMBED</span><strong>{embed.title}</strong><small>BY {embed.author.toUpperCase()}</small></div>
|
||||
<b>{embed.ownerShared ? 'AUTHOR-SHARED' : 'PUBLIC ARCHIVE'}</b>
|
||||
</header>
|
||||
{#if embed.available}
|
||||
<div class="build-embed-matrix">
|
||||
<section><span>WEAPONS</span>{#each embed.weapons as weapon}<div><strong>{weapon.name}</strong><small>{weapon.category}</small></div>{/each}</section>
|
||||
<section><span>GEAR</span>{#each embed.gear.slice(0,6) as item}<div><strong>{item.name}</strong><small>{item.slot}</small></div>{/each}</section>
|
||||
</div>
|
||||
{#if !embed.ownerShared}<a href={`/builds/${encodeURIComponent(embed.slug)}`}>OPEN CURRENT BUILD →</a>{/if}
|
||||
{:else}
|
||||
<div class="build-embed-unavailable"><strong>BUILD UNAVAILABLE</strong><span>The source Build is no longer public.</span></div>
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.build-embed{margin:32px 0;border:1px solid var(--shd-border-strong);background:#0d1011;font-family:var(--shd-font)}
|
||||
.build-embed>header{position:static;z-index:auto;width:auto;height:auto;min-height:0;display:flex;align-items:start;justify-content:space-between;gap:16px;padding:14px;border-bottom:1px solid var(--shd-border);background:var(--shd-surface-raised)}.build-embed>header>div{display:grid;gap:3px}.build-embed span,.build-embed small,.build-embed b,.build-embed>a{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.build-embed>header span,.build-embed>a{color:var(--shd-orange)}.build-embed>header strong{font-size:1.18rem}.build-embed>header small,.build-embed>header>b{color:var(--shd-text-muted)}
|
||||
.build-embed-matrix{display:grid;grid-template-columns:.45fr 1fr}.build-embed-matrix section{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));border-left:1px solid var(--shd-border)}.build-embed-matrix section:first-child{border-left:0}.build-embed-matrix section>span{grid-column:1/-1;padding:7px 10px;border-bottom:1px solid var(--shd-border);color:var(--shd-text-muted)}.build-embed-matrix section>div{min-width:0;display:grid;gap:2px;padding:9px 10px;border-left:1px solid var(--shd-border);border-top:1px solid var(--shd-border)}.build-embed-matrix section>div:nth-child(2),.build-embed-matrix section>div:nth-child(3){border-top:0}.build-embed-matrix section>div:nth-child(even){border-left:0}.build-embed-matrix strong,.build-embed-matrix small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.build-embed-matrix small{color:var(--shd-text-muted)}
|
||||
.build-embed>a{min-height:42px;display:flex;align-items:center;justify-content:flex-end;padding:0 12px;border-top:1px solid var(--shd-border)}.build-embed>a:hover,.build-embed>a:focus-visible{background:var(--shd-surface-active);color:var(--shd-orange-hover)}.build-embed-unavailable{display:grid;gap:4px;padding:22px}.build-embed-unavailable strong{color:var(--shd-danger)}.build-embed-unavailable span{color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}
|
||||
@media(max-width:700px){.build-embed>header{display:grid}.build-embed-matrix{grid-template-columns:1fr}.build-embed-matrix section{border-top:1px solid var(--shd-border);border-left:0}.build-embed-matrix section:first-child{border-top:0}}
|
||||
</style>
|
||||
@@ -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<string, unknown> | null;
|
||||
plain?: boolean;
|
||||
menuItem?: boolean;
|
||||
itemId?: string;
|
||||
} = $props();
|
||||
|
||||
const categories = [
|
||||
@@ -48,6 +50,7 @@
|
||||
{#snippet children(pending)}
|
||||
<input type="hidden" name="contentType" value={contentType}/>
|
||||
<input type="hidden" name="contentId" value={contentId}/>
|
||||
{#if itemId}<input type="hidden" name="itemId" value={itemId}/>{/if}
|
||||
<label class="field">CONCERN
|
||||
<select name="category" required>
|
||||
{#each categories as category}<option value={category.value}>{category.label}</option>{/each}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { tick } from 'svelte';
|
||||
import type { DatabaseItem } from '$lib/database/items';
|
||||
import DatabaseItemCard from './DatabaseItemCard.svelte';
|
||||
import GameDataDiscussion from './GameDataDiscussion.svelte';
|
||||
|
||||
let { item, comments, viewer, form, signInHref, closeHref }: {
|
||||
item: DatabaseItem;
|
||||
comments: Array<any>;
|
||||
viewer: { id: string | null; canComment: boolean; canPin: boolean };
|
||||
form?: Record<string, any> | null;
|
||||
signInHref: string;
|
||||
closeHref: string;
|
||||
} = $props();
|
||||
let dialog = $state<HTMLDivElement | null>(null);
|
||||
let closeButton = $state<HTMLButtonElement | null>(null);
|
||||
|
||||
$effect(() => { void tick().then(() => closeButton?.focus()); });
|
||||
const close = () => goto(closeHref, { noScroll: true });
|
||||
const trapFocus = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Tab' || !dialog) return;
|
||||
const focusable = [...dialog.querySelectorAll<HTMLElement>('a[href],button:not(:disabled),textarea,input:not([type="hidden"]),[tabindex]:not([tabindex="-1"])')].filter((element) => !element.hasAttribute('hidden'));
|
||||
const first = focusable[0];
|
||||
const last = focusable.at(-1);
|
||||
if (!first || !last) return;
|
||||
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
|
||||
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={(event) => { if (event.key === 'Escape') void close(); }}/>
|
||||
|
||||
<div class="database-discussion-backdrop" role="presentation" onclick={(event) => { if (event.target === event.currentTarget) void close(); }}>
|
||||
<div class="database-discussion-dialog" bind:this={dialog} role="dialog" aria-modal="true" aria-labelledby="database-discussion-title" tabindex="-1" onkeydown={trapFocus}>
|
||||
<header class="database-discussion-command"><div><small>DATABASE RECORD / COMMUNITY DISCUSSION</small><strong id="database-discussion-title">{item.name}</strong></div><button type="button" bind:this={closeButton} aria-label="Close discussion" onclick={() => void close()}>×</button></header>
|
||||
<div class="database-discussion-scroll">
|
||||
<section class="database-discussion-context" aria-label={`${item.name} record context`}><DatabaseItemCard {item} hideDiscussionUtility anchor={false}/></section>
|
||||
<GameDataDiscussion itemName={item.name} itemId={item.id} {comments} count={item.discussionCount ?? 0} {viewer} {form} {signInHref} embedded/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.database-discussion-backdrop{position:fixed;z-index:1200;inset:0;display:grid;place-items:center;padding:20px;background:rgba(3,5,5,.86);backdrop-filter:blur(3px)}.database-discussion-dialog{width:min(920px,100%);max-height:calc(100vh - 40px);display:grid;grid-template-rows:auto minmax(0,1fr);overflow:hidden;border:1px solid var(--shd-border-strong);border-top:2px solid var(--shd-orange);background:var(--shd-bg);box-shadow:0 18px 60px #000}.database-discussion-command{min-height:58px;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:10px 12px;border-bottom:1px solid var(--shd-border);background:var(--shd-surface-active)}.database-discussion-command>div{display:grid;gap:3px}.database-discussion-command small{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.database-discussion-command strong{font-size:var(--shd-type-body);letter-spacing:.2px}.database-discussion-command button{width:34px;height:34px;padding:0;border:1px solid var(--shd-border-strong);background:var(--shd-surface);color:var(--shd-text-muted);font-size:var(--shd-type-title);line-height:1;cursor:pointer}.database-discussion-command button:hover,.database-discussion-command button:focus-visible{border-color:var(--shd-orange);color:var(--shd-orange);background:var(--shd-surface-active)}.database-discussion-command button:focus-visible{outline:2px solid var(--shd-orange);outline-offset:2px}.database-discussion-scroll{min-height:0;overflow:auto;padding:14px 14px 190px}.database-discussion-context{width:min(720px,100%);margin:0 auto}.database-discussion-context :global(.database-item-card){box-shadow:none}.database-discussion-scroll :global(.public-discussion){width:min(720px,100%);margin:24px auto 0}
|
||||
@media(max-width:650px){.database-discussion-backdrop{padding:0}.database-discussion-dialog{width:100%;height:100%;max-height:none;border-right:0;border-bottom:0;border-left:0}.database-discussion-command{min-height:64px}.database-discussion-command button{width:44px;height:44px}.database-discussion-scroll{padding:10px 10px 190px}.database-discussion-context{width:100%}}
|
||||
</style>
|
||||
@@ -5,15 +5,20 @@
|
||||
import DatabaseFacts from './DatabaseFacts.svelte';
|
||||
import type { GameIconName } from '$lib/game-icons';
|
||||
import type { DatabaseItem } from '$lib/database/items';
|
||||
import MarkdownContent from './MarkdownContent.svelte';
|
||||
|
||||
let {
|
||||
item
|
||||
item,
|
||||
hideDiscussionUtility = false,
|
||||
anchor = true
|
||||
}: {
|
||||
item: DatabaseItem & { gameIcon?: GameIconName }
|
||||
item: DatabaseItem & { gameIcon?: GameIconName };
|
||||
hideDiscussionUtility?: boolean;
|
||||
anchor?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<article class="database-item-card" class:talent-card={item.variant === 'talent'}>
|
||||
<article id={anchor ? `database-item-${item.id}` : undefined} class="database-item-card" class:talent-card={item.variant === 'talent'}>
|
||||
<header>
|
||||
{#if item.renderUrl}<img class="database-item-render" src={item.renderUrl} alt="" loading="lazy"/>{/if}
|
||||
<div class="database-item-icon">
|
||||
@@ -55,7 +60,30 @@
|
||||
{#if item.afterLines?.length}
|
||||
<div class="database-item-lines after-description">{#each item.afterLines as line}<div><span>{line.label}</span><b>{line.value}</b></div>{/each}</div>
|
||||
{/if}
|
||||
{#if item.actions?.length}
|
||||
<footer class="database-item-actions">{#each item.actions as action}<a href={action.href}>{action.label} →</a>{/each}</footer>
|
||||
{#if item.pinnedCommunityNoteHtml}<aside class="database-community-note"><strong>COMMUNITY NOTE</strong><MarkdownContent html={item.pinnedCommunityNoteHtml} compact/></aside>{/if}
|
||||
{#if item.actions?.length || item.correctionHref || (!hideDiscussionUtility && item.discussionHref)}
|
||||
<footer class="database-item-footer">
|
||||
{#if item.actions?.length}<div class="database-item-actions">{#each item.actions as action}<a href={action.href}>{action.label} →</a>{/each}</div>{/if}
|
||||
<div class="database-item-utilities" aria-label="Community actions">
|
||||
{#if item.correctionHref}
|
||||
<a class="database-utility-button correction" href={item.correctionHref} aria-label={`Report an inaccuracy in ${item.name}`} title="Report an inaccuracy">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 21V4M6 5h11l-2.75 3L17 11H6"/></svg>
|
||||
</a>
|
||||
{/if}
|
||||
{#if !hideDiscussionUtility && item.discussionHref}
|
||||
<a class="database-utility-button discussion" href={item.discussionHref} aria-label={`Open discussion for ${item.name}${item.discussionCount ? `, ${item.discussionCount} messages` : ', no messages yet'}`} title="Open discussion" aria-haspopup="dialog">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 5.75h14v9.5H9.25L5 18.75v-13Z"/><path d="M8.25 9h7.5M8.25 12h5"/></svg>
|
||||
{#if item.discussionCount}<span>{item.discussionCount}</span>{/if}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</footer>
|
||||
{/if}
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.database-community-note{display:grid;gap:5px;margin:12px 0 0;padding:10px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-armor-border);background:linear-gradient(90deg,var(--shd-armor-surface),var(--shd-surface-raised))}.database-community-note>strong{color:var(--shd-armor-label);font-size:var(--shd-type-telemetry);letter-spacing:.7px}.database-community-note :global(.markdown-content){color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.database-community-note :global(.markdown-content a){color:var(--shd-armor-text)}
|
||||
.database-item-footer{display:flex;align-items:flex-end;justify-content:space-between;gap:8px;margin:10px 12px 12px;padding-top:9px;border-top:1px solid var(--shd-border)}.database-item-footer .database-item-actions{min-width:0;display:flex;flex-wrap:wrap;gap:6px;margin:0;padding:0;border:0}.database-item-utilities{display:flex;align-items:center;gap:4px;margin-left:auto}.database-utility-button{height:32px;min-width:32px;display:inline-flex;align-items:center;justify-content:center;gap:4px;padding:0 7px;border:1px solid var(--shd-border);background:var(--shd-surface);color:var(--shd-text-dim)}.database-utility-button svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:1.6;stroke-linecap:square;stroke-linejoin:miter}.database-utility-button span{min-width:10px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:800;line-height:1;text-align:center}.database-utility-button:hover,.database-utility-button:focus-visible{border-color:var(--shd-border-strong);background:var(--shd-surface-active);color:var(--shd-text)}.database-utility-button:focus-visible{outline:2px solid var(--shd-orange);outline-offset:2px}.database-utility-button:active{transform:translateY(1px);filter:brightness(.84)}
|
||||
@media(max-width:650px){.database-item-footer{align-items:stretch}.database-item-utilities{align-self:end}.database-utility-button{width:44px;height:44px;padding:0}.database-utility-button.discussion:has(span){width:auto;min-width:44px;padding:0 10px}}
|
||||
@media(prefers-reduced-motion:reduce){.database-utility-button{transition:none}}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<script lang="ts">
|
||||
import AgentIdentityLink from './AgentIdentityLink.svelte';
|
||||
import CommentComposer from './CommentComposer.svelte';
|
||||
import MarkdownContent from './MarkdownContent.svelte';
|
||||
import ModerationDisclosure from './ModerationDisclosure.svelte';
|
||||
import PendingForm from './PendingForm.svelte';
|
||||
import OverflowMenu from './OverflowMenu.svelte';
|
||||
import ContentReport from './ContentReport.svelte';
|
||||
import { tick } from 'svelte';
|
||||
|
||||
let { itemName, itemId, comments, count, viewer, form, signInHref, embedded = false }: {
|
||||
itemName: string;
|
||||
itemId: string;
|
||||
comments: Array<any>;
|
||||
count: number;
|
||||
viewer: { id: string | null; canComment: boolean; canPin: boolean };
|
||||
form?: Record<string, any> | null;
|
||||
signInHref: string;
|
||||
embedded?: boolean;
|
||||
} = $props();
|
||||
let draft = $state('');
|
||||
let activeComposer = $state<string | null>(null);
|
||||
let handledToken = $state('');
|
||||
let activeMenuId = $state('');
|
||||
let deletingCommentId = $state('');
|
||||
const parentForResult = $derived(String(form?.discussionParentId ?? '') || 'root');
|
||||
|
||||
$effect(() => {
|
||||
const token = String(form?.discussionResultToken ?? '');
|
||||
if (!token || token === handledToken) return;
|
||||
handledToken = token;
|
||||
if (form?.discussionSaved) { draft = ''; activeComposer = null; }
|
||||
});
|
||||
async function openComposer(id: string) {
|
||||
activeComposer = activeComposer === id ? null : id;
|
||||
draft = '';
|
||||
if (!activeComposer) return;
|
||||
await tick();
|
||||
document.getElementById(`discussion-editor-${activeComposer}`)?.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet composer(parentId: string | null, label: string)}
|
||||
<PendingForm action="?/commentDiscussion" class="discussion-form">
|
||||
<input type="hidden" name="itemId" value={itemId}/>
|
||||
{#if parentId}<input type="hidden" name="parentId" value={parentId}/>{/if}
|
||||
<CommentComposer id={`discussion-editor-${parentId ?? 'root'}`} name="body" {label} maxLength={2000} rows={4} required bind:value={draft}/>
|
||||
<ModerationDisclosure compact/>
|
||||
{#if parentForResult === (parentId ?? 'root') && form?.discussionError}<p class="error">{form.discussionError}</p>{/if}
|
||||
<div class="discussion-form-actions"><button type="button" class="secondary" onclick={() => openComposer(parentId ?? 'root')}>CANCEL</button><button class="primary" disabled={!draft.trim()}>{parentId ? 'POST REPLY →' : 'POST NOTE →'}</button></div>
|
||||
</PendingForm>
|
||||
{/snippet}
|
||||
|
||||
<section class="public-discussion" class:embedded id="discussion">
|
||||
<header><div><p class="eyebrow">COMMUNITY INTELLIGENCE</p><h2>FIELD DISCUSSION</h2></div><span>{count} {count === 1 ? 'NOTE' : 'NOTES'}</span></header>
|
||||
{#if viewer.canComment}
|
||||
<button type="button" class="discussion-trigger" aria-expanded={activeComposer === 'root'} onclick={() => openComposer('root')}>{activeComposer === 'root' ? 'CLOSE EDITOR' : `DISCUSS ${itemName.toUpperCase()}`}</button>
|
||||
{#if activeComposer === 'root'}{@render composer(null, 'NEW COMMUNITY NOTE')}{/if}
|
||||
{:else if !viewer.id}
|
||||
<div class="discussion-access"><strong>SIGN-IN REQUIRED</strong><p>Sign in to discuss this data record or propose a correction.</p><a class="secondary" href={signInHref}>SIGN IN →</a></div>
|
||||
{/if}
|
||||
<div class="discussion-list">
|
||||
{#each comments as comment}
|
||||
<article id={`discussion-comment-${comment.id}`} class:reply={comment.depth > 0} class:removed={comment.status === 'removed'} class:pinned={comment.isPinned} style={`--thread-depth:${Math.min(comment.depth, 4)}`}>
|
||||
{#if comment.status === 'visible'}
|
||||
<header><AgentIdentityLink agent={comment.author} roles={comment.author.roles} meta={new Date(comment.createdAt).toLocaleDateString().toUpperCase()}/>{#if comment.isPinned}<b>COMMUNITY NOTE</b>{/if}</header>
|
||||
<MarkdownContent html={comment.bodyHtml} compact/>
|
||||
<div class="discussion-actions">
|
||||
{#if viewer.canComment}<button type="button" onclick={() => openComposer(comment.id)}>REPLY</button>{/if}
|
||||
{#if viewer.id}
|
||||
<OverflowMenu label={`Actions for ${comment.author.displayName}'s message`} bind:open={() => activeMenuId === comment.id, (open) => activeMenuId = open ? comment.id : ''}>
|
||||
{#snippet children(close)}
|
||||
<PendingForm action="?/toggleDiscussionFollow" successMessage={comment.isFollowing ? 'Conversation unfollowed.' : 'Conversation followed.'}>
|
||||
<input type="hidden" name="itemId" value={itemId}/>
|
||||
<input type="hidden" name="commentId" value={comment.id}/>
|
||||
<input type="hidden" name="intent" value={comment.isFollowing ? 'unfollow' : 'follow'}/>
|
||||
<button>{comment.isFollowing ? 'UNFOLLOW THREAD' : 'FOLLOW THREAD'}</button>
|
||||
</PendingForm>
|
||||
{#if comment.canReport}<ContentReport contentType="comment" contentId={comment.id} {itemId} result={form} plain menuItem/>{/if}
|
||||
{#if viewer.canPin && comment.depth === 0}
|
||||
<PendingForm action="?/setPinnedCommunityNote">
|
||||
<input type="hidden" name="itemId" value={itemId}/>
|
||||
<input type="hidden" name="commentId" value={comment.id}/>
|
||||
<input type="hidden" name="intent" value={comment.isPinned ? 'unpin' : 'pin'}/>
|
||||
<button>{comment.isPinned ? 'UNPIN COMMUNITY NOTE' : 'PIN AS COMMUNITY NOTE'}</button>
|
||||
</PendingForm>
|
||||
{/if}
|
||||
{#if comment.canDelete}<button type="button" class="danger" onclick={() => { deletingCommentId = comment.id; close(); }}>REMOVE MESSAGE</button>{/if}
|
||||
{/snippet}
|
||||
</OverflowMenu>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}<div class="discussion-removed"><strong>{comment.removalReason === 'Removed by Community Oversight' ? 'MESSAGE REMOVED BY COMMUNITY OVERSIGHT' : 'MESSAGE REMOVED BY POSTER'}</strong><span>Replies remain visible to preserve the conversation.</span></div>{/if}
|
||||
{#if activeComposer === comment.id}<div class="discussion-reply">{@render composer(comment.id, `REPLY TO ${comment.author.displayName.toUpperCase()}`)}</div>{/if}
|
||||
{#if deletingCommentId === comment.id}
|
||||
<div class="discussion-delete-confirm" role="alert">
|
||||
<p>Remove this message? It remains as a labeled placeholder only when replies depend on it.</p>
|
||||
<div><button type="button" class="secondary" onclick={() => deletingCommentId = ''}>CANCEL</button><PendingForm action="?/deleteDiscussionComment"><input type="hidden" name="itemId" value={itemId}/><input type="hidden" name="commentId" value={comment.id}/><button class="primary">CONFIRM REMOVE</button></PendingForm></div>
|
||||
</div>
|
||||
{/if}
|
||||
</article>
|
||||
{:else}<p class="discussion-empty">No active field discussion. Use a note to document an observation or help verify this record.</p>{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.public-discussion.embedded{padding:0 0 2px;border:0;background:transparent}.public-discussion.embedded>header{padding:0;border:0;background:transparent}
|
||||
.public-discussion{margin-top:28px;padding-top:18px;border-top:1px solid var(--shd-border)}.public-discussion>header{display:flex;justify-content:space-between;gap:12px;align-items:end}.public-discussion h2,.public-discussion p{margin:0}.public-discussion h2{font-size:var(--shd-type-title)}.public-discussion>header span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.discussion-trigger{width:100%;min-height:46px;margin-top:14px;padding:0 12px;border:1px solid var(--shd-border-strong);background:transparent;color:var(--shd-text-muted);text-align:left;cursor:pointer}.discussion-trigger:hover,.discussion-trigger:focus-visible{border-color:var(--shd-orange);color:var(--shd-text);background:var(--shd-surface-active)}:global(form.discussion-form){display:grid;gap:9px;margin-top:12px}.discussion-form-actions{display:flex;justify-content:end;gap:7px}.discussion-list{width:100%;display:grid;margin-top:26px;overflow:visible}.discussion-list article{position:relative;display:grid;gap:8px;margin:18px 0 0 calc(var(--thread-depth) * 22px);padding:0;border:0;background:transparent}.discussion-list article.reply{padding-left:14px;border-left:1px solid var(--shd-border-strong)}.discussion-list article.pinned{box-shadow:none}.discussion-list article>header{position:static;z-index:auto;width:auto;height:auto;min-height:0;display:flex;align-items:center;justify-content:flex-start;gap:12px;padding:0;border:0;background:transparent}.discussion-list article>header>b{margin-left:auto;color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.7px}.discussion-list :global(.markdown-content){max-width:75ch;margin-left:53px}.discussion-actions{min-height:32px;display:flex;align-items:center;gap:14px;margin-left:53px}.discussion-reply,.discussion-delete-confirm{margin:6px 0 0 53px}.discussion-reply :global(form.discussion-form){padding-top:0}.discussion-actions button{border:0;background:transparent;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.65px;cursor:pointer}.discussion-actions button:hover,.discussion-actions button:focus-visible{color:var(--shd-orange);text-decoration:underline;text-underline-offset:3px}.discussion-removed{display:grid;gap:3px;margin-left:53px;padding:2px 0;color:var(--shd-text-dim)}.discussion-removed strong{font-size:var(--shd-type-telemetry);letter-spacing:.7px}.discussion-removed span,.discussion-empty,.discussion-access{color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.discussion-delete-confirm{width:min(520px,calc(100% - 53px));display:grid;gap:8px}.discussion-delete-confirm>div{display:flex;justify-content:flex-end;gap:7px}
|
||||
.discussion-access,.discussion-empty{display:grid;gap:5px;padding:12px;border:1px solid var(--shd-border);background:var(--shd-surface-raised);color:var(--shd-text-muted)}.discussion-access strong{color:var(--shd-text);font-size:var(--shd-type-telemetry);letter-spacing:.7px}.discussion-access{margin-top:14px}.discussion-access a{justify-self:start}.discussion-empty{margin-top:16px;font-family:var(--shd-font-editorial)}@media(max-width:650px){.public-discussion>header{align-items:flex-start;flex-direction:column}.discussion-list article{margin-left:calc(var(--thread-depth) * 10px)}.discussion-list article.reply{padding-left:10px}.discussion-list :global(.markdown-content),.discussion-actions,.discussion-reply,.discussion-removed,.discussion-delete-confirm{margin-left:0}.discussion-delete-confirm{width:100%}.discussion-form-actions{display:grid;grid-template-columns:1fr 1fr}.discussion-form-actions button{width:100%;min-height:44px}.discussion-actions>button{min-height:44px}.discussion-access .secondary{min-height:44px}.discussion-list article>header>b{align-self:start}}
|
||||
</style>
|
||||
@@ -73,7 +73,7 @@
|
||||
aria-expanded={open}
|
||||
onclick={toggle}
|
||||
>
|
||||
<span aria-hidden="true">•••</span>
|
||||
<span aria-hidden="true">...</span>
|
||||
</button>
|
||||
{#if open}
|
||||
<div class="overflow-menu-panel" role="toolbar" aria-label={label} tabindex="-1" onkeydown={navigate}>
|
||||
@@ -83,7 +83,7 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.overflow-menu{position:relative}.overflow-menu-trigger{width:44px;height:44px;display:grid;place-items:center;padding:0;border:1px solid transparent;background:transparent;color:var(--shd-text-muted);font-size:15px;font-weight:900;letter-spacing:2px;cursor:pointer}.overflow-menu-trigger:hover,.overflow-menu-trigger:focus-visible,.overflow-menu-trigger[aria-expanded=true]{border-color:var(--shd-border-strong);background:var(--shd-surface-active);color:var(--shd-orange)}.overflow-menu-panel{position:absolute;z-index:7;top:calc(100% + 4px);left:0;width:max-content;min-width:210px;display:grid;padding:4px;border:1px solid var(--shd-border-strong);background:var(--shd-surface);box-shadow:8px 12px 28px #0009}.overflow-menu-panel :global(form>fieldset>button),.overflow-menu-panel :global(details>summary),.overflow-menu-panel :global(>button){width:100%;min-height:40px;display:flex;align-items:center;justify-content:flex-start;padding:0 10px;border:0;background:transparent;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.65px;text-align:left;cursor:pointer}.overflow-menu-panel :global(form>fieldset>button:hover),.overflow-menu-panel :global(form>fieldset>button:focus-visible),.overflow-menu-panel :global(details>summary:hover),.overflow-menu-panel :global(details>summary:focus-visible),.overflow-menu-panel :global(>button:hover),.overflow-menu-panel :global(>button:focus-visible){background:var(--shd-surface-active);color:var(--shd-orange)}.overflow-menu-panel :global(>button.danger:hover),.overflow-menu-panel :global(>button.danger:focus-visible){color:var(--shd-danger)}
|
||||
.overflow-menu{position:relative}.overflow-menu-trigger{width:auto;min-width:32px;height:32px;display:grid;place-items:center;padding:0 3px;border:0;background:transparent;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);font-weight:900;letter-spacing:1px;cursor:pointer}.overflow-menu-trigger:hover,.overflow-menu-trigger:focus-visible,.overflow-menu-trigger[aria-expanded=true]{background:transparent;color:var(--shd-orange);text-decoration:underline;text-underline-offset:3px}.overflow-menu-panel{position:absolute;z-index:7;top:calc(100% + 4px);left:0;width:max-content;min-width:210px;display:grid;padding:4px;border:1px solid var(--shd-border-strong);background:var(--shd-surface);box-shadow:8px 12px 28px #0009}.overflow-menu-panel :global(form>fieldset>button),.overflow-menu-panel :global(details>summary),.overflow-menu-panel :global(>button){width:100%;min-height:40px;display:flex;align-items:center;justify-content:flex-start;padding:0 10px;border:0;background:transparent;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.65px;text-align:left;cursor:pointer}.overflow-menu-panel :global(form>fieldset>button:hover),.overflow-menu-panel :global(form>fieldset>button:focus-visible),.overflow-menu-panel :global(details>summary:hover),.overflow-menu-panel :global(details>summary:focus-visible),.overflow-menu-panel :global(>button:hover),.overflow-menu-panel :global(>button:focus-visible){background:var(--shd-surface-active);color:var(--shd-orange)}.overflow-menu-panel :global(>button.danger:hover),.overflow-menu-panel :global(>button.danger:focus-visible){color:var(--shd-danger)}
|
||||
@media(max-width:650px){.overflow-menu-panel{position:fixed;z-index:12;top:auto;right:12px;bottom:12px;left:12px;width:auto;padding:6px}.overflow-menu-panel :global(form>fieldset>button),.overflow-menu-panel :global(details>summary),.overflow-menu-panel :global(>button){min-height:48px}}
|
||||
@media(prefers-reduced-motion:reduce){.overflow-menu-trigger{transition:none}}
|
||||
</style>
|
||||
|
||||
@@ -1,15 +1,61 @@
|
||||
export const databaseCategories = [
|
||||
{ slug: 'gear', label: 'Gear', description: 'Equipment slots, base health and armor, tiers, Brand Sets, and talents.' },
|
||||
{ slug: 'weapons', label: 'Weapons', description: 'Weapon families, damage types, tier ranges, handling, talents, and bonuses.' },
|
||||
{ slug: 'weapon-mods', label: 'Weapon Mods', description: 'Slot and weapon compatibility, quality rolls, secondary pools, and Mod Effects.' },
|
||||
{ slug: 'gear-mods', label: 'Gear Mods', description: 'Quality-scaled primary rolls and selectable secondary attribute pools.' },
|
||||
{ slug: 'os-protocols', label: 'OS Protocols', description: 'Quality, core profile, primary effect, and unique upgradeable talent.' },
|
||||
{ slug: 'talents', label: 'Talents', description: 'Equipment and weapon talents with tiered descriptions and combat mechanics.' },
|
||||
{ slug: 'brands', label: 'Brands', description: 'Brand Set identities and their two-, three-, and four-piece bonuses.' },
|
||||
{ slug: 'skill-chips', label: 'Skill Chips', description: 'Set bonuses, primary and secondary pools, and quality-scaled unique talents.' },
|
||||
{ slug: 'specializations', label: 'Specializations', description: 'Specialization identities, focus paths, signature abilities, and tree node progression.' }
|
||||
{
|
||||
slug: 'gear',
|
||||
label: 'Gear',
|
||||
description:
|
||||
'Equipment slots, base health and armor, tiers, Brand Sets, and talents.',
|
||||
},
|
||||
{
|
||||
slug: 'weapons',
|
||||
label: 'Weapons',
|
||||
description:
|
||||
'Weapon families, damage types, tier ranges, handling, talents, and bonuses.',
|
||||
},
|
||||
{
|
||||
slug: 'weapon-mods',
|
||||
label: 'Weapon Mods',
|
||||
description:
|
||||
'Slot and weapon compatibility, quality rolls, secondary pools, and Mod Effects.',
|
||||
},
|
||||
{
|
||||
slug: 'gear-mods',
|
||||
label: 'Gear Mods',
|
||||
description:
|
||||
'Quality-scaled primary rolls and selectable secondary attribute pools.',
|
||||
},
|
||||
{
|
||||
slug: 'os-protocols',
|
||||
label: 'OS Protocols',
|
||||
description:
|
||||
'Quality, core profile, primary effect, and unique upgradeable talent.',
|
||||
},
|
||||
{
|
||||
slug: 'talents',
|
||||
label: 'Talents',
|
||||
description:
|
||||
'Equipment and weapon talents with tiered descriptions and combat mechanics.',
|
||||
},
|
||||
{
|
||||
slug: 'brands',
|
||||
label: 'Brands',
|
||||
description:
|
||||
'Brand Set identities and their two-, three-, and four-piece bonuses.',
|
||||
},
|
||||
{
|
||||
slug: 'skill-chips',
|
||||
label: 'Skill Chips',
|
||||
description:
|
||||
'Set bonuses, primary and secondary pools, and quality-scaled unique talents.',
|
||||
},
|
||||
{
|
||||
slug: 'specializations',
|
||||
label: 'Specializations',
|
||||
description:
|
||||
'Specialization identities, focus paths, signature abilities, and tree node progression.',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type DatabaseCategorySlug = typeof databaseCategories[number]['slug'];
|
||||
export type DatabaseCategorySlug = (typeof databaseCategories)[number]['slug'];
|
||||
|
||||
export const databaseCategory = (slug: string) => databaseCategories.find((category) => category.slug === slug);
|
||||
export const databaseCategory = (slug: string) =>
|
||||
databaseCategories.find((category) => category.slug === slug);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { GameIconName } from '$lib/game-icons';
|
||||
import type { GameDataTargetType } from '$lib/game-data-targets';
|
||||
|
||||
export type DatabaseBadge = { label: string; tone?: string };
|
||||
export type DatabaseMeter = { label: string; value: number };
|
||||
@@ -13,6 +14,11 @@ export type DatabaseFact = {
|
||||
|
||||
export type DatabaseItem = {
|
||||
id: string;
|
||||
targetType?: GameDataTargetType;
|
||||
correctionHref?: string;
|
||||
discussionHref?: string;
|
||||
discussionCount?: number;
|
||||
pinnedCommunityNoteHtml?: string;
|
||||
name: string;
|
||||
kicker: string;
|
||||
variant?: 'talent';
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
gameDataCorrectionHref,
|
||||
gameDataDiscussionId,
|
||||
gameDataTarget,
|
||||
} from './game-data-targets';
|
||||
|
||||
describe('game-data public targets', () => {
|
||||
it('maps every public category to its ISAC registry', () => {
|
||||
expect(gameDataTarget('weapons')).toEqual({
|
||||
type: 'weapon',
|
||||
module: 'weapons',
|
||||
});
|
||||
expect(gameDataTarget('weapon-mods')).toEqual({
|
||||
type: 'weapon_mod',
|
||||
module: 'weaponmods',
|
||||
});
|
||||
expect(gameDataTarget('skill-chips')).toEqual({
|
||||
type: 'skill_chip',
|
||||
module: 'skillchips',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses a stable exact-item correction link and discussion identity', () => {
|
||||
expect(gameDataCorrectionHref('talents', 'talent id')).toBe(
|
||||
'/isac/talents?item=talent%20id',
|
||||
);
|
||||
expect(gameDataDiscussionId('talent', 'talent-id')).toBe(
|
||||
'talent:talent-id',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { DatabaseCategorySlug } from '$lib/database/categories';
|
||||
|
||||
export const gameDataTargetTypes = [
|
||||
'gear',
|
||||
'weapon',
|
||||
'weapon_mod',
|
||||
'gear_mod',
|
||||
'os_protocol',
|
||||
'talent',
|
||||
'brand_set',
|
||||
'skill_chip',
|
||||
'specialization',
|
||||
] as const;
|
||||
|
||||
export type GameDataTargetType = (typeof gameDataTargetTypes)[number];
|
||||
|
||||
const targets: Record<
|
||||
DatabaseCategorySlug,
|
||||
{ type: GameDataTargetType; module: string }
|
||||
> = {
|
||||
gear: { type: 'gear', module: 'gear' },
|
||||
weapons: { type: 'weapon', module: 'weapons' },
|
||||
'weapon-mods': { type: 'weapon_mod', module: 'weaponmods' },
|
||||
'gear-mods': { type: 'gear_mod', module: 'gearmods' },
|
||||
'os-protocols': { type: 'os_protocol', module: 'protocols' },
|
||||
talents: { type: 'talent', module: 'talents' },
|
||||
brands: { type: 'brand_set', module: 'brands' },
|
||||
'skill-chips': { type: 'skill_chip', module: 'skillchips' },
|
||||
specializations: { type: 'specialization', module: 'specializations' },
|
||||
};
|
||||
|
||||
export const gameDataTarget = (category: DatabaseCategorySlug) =>
|
||||
targets[category];
|
||||
|
||||
export const gameDataDiscussionId = (type: GameDataTargetType, id: string) =>
|
||||
`${type}:${id}`;
|
||||
|
||||
export const gameDataCorrectionHref = (
|
||||
category: DatabaseCategorySlug,
|
||||
id: string,
|
||||
) => `/isac/${targets[category].module}?item=${encodeURIComponent(id)}`;
|
||||
+13
-4
@@ -6,9 +6,18 @@ export const gameIcons = {
|
||||
toughness: { src: '/toughness.png', label: 'Toughness' },
|
||||
engineering: { src: '/engineering.png', label: 'Engineering' },
|
||||
bulwark: { src: '/bulwark.png', label: 'Bulwark specialization' },
|
||||
combat_medic: { src: '/combat_medic.png', label: 'Combat Medic specialization' },
|
||||
demolitionist: { src: '/demolitionist.png', label: 'Demolitionist specialization' },
|
||||
tech_operator: { src: '/tech_operator.png', label: 'Tech Operator specialization' },
|
||||
combat_medic: {
|
||||
src: '/combat_medic.png',
|
||||
label: 'Combat Medic specialization',
|
||||
},
|
||||
demolitionist: {
|
||||
src: '/demolitionist.png',
|
||||
label: 'Demolitionist specialization',
|
||||
},
|
||||
tech_operator: {
|
||||
src: '/tech_operator.png',
|
||||
label: 'Tech Operator specialization',
|
||||
},
|
||||
vanguard: { src: '/vanguard.png', label: 'Vanguard specialization' },
|
||||
legendary: { src: '/legendary.png', label: 'Legendary difficulty' },
|
||||
shd_ticket: { src: '/shd_ticket.png', label: 'SHD ticket' },
|
||||
@@ -24,7 +33,7 @@ export const gameIcons = {
|
||||
mmr: { src: '/mmr.png', label: 'Marksman rifle weapon category' },
|
||||
ar: { src: '/ar.png', label: 'Assault rifle weapon category' },
|
||||
lmg: { src: '/lmg.png', label: 'Light machine gun weapon category' },
|
||||
smg: { src: '/smg.png', label: 'Submachine gun weapon category' }
|
||||
smg: { src: '/smg.png', label: 'Submachine gun weapon category' },
|
||||
} as const;
|
||||
|
||||
export type GameIconName = keyof typeof gameIcons;
|
||||
|
||||
+39
-13
@@ -1,4 +1,5 @@
|
||||
const escapeHtml = (value: string) => value
|
||||
const escapeHtml = (value: string) =>
|
||||
value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
@@ -8,7 +9,9 @@ const escapeHtml = (value: string) => value
|
||||
function safeLink(raw: string) {
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
return url.protocol === 'https:' || url.protocol === 'http:' ? url.toString() : null;
|
||||
return url.protocol === 'https:' || url.protocol === 'http:'
|
||||
? url.toString()
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -16,12 +19,17 @@ function safeLink(raw: string) {
|
||||
|
||||
function inlineMarkdown(value: string) {
|
||||
const code: string[] = [];
|
||||
let output = escapeHtml(value).replace(/`([^`\r\n]+)`/g, (_match, body: string) => {
|
||||
let output = escapeHtml(value).replace(
|
||||
/`([^`\r\n]+)`/g,
|
||||
(_match, body: string) => {
|
||||
const token = `\u0000CODE${code.length}\u0000`;
|
||||
code.push(`<code>${body}</code>`);
|
||||
return token;
|
||||
});
|
||||
output = output.replace(/\[([^\]\r\n]{1,240})\]\(([^)\s]{1,2048})\)/g, (_match, label: string, href: string) => {
|
||||
},
|
||||
);
|
||||
output = output.replace(
|
||||
/\[([^\]\r\n]{1,240})\]\(([^)\s]{1,2048})\)/g,
|
||||
(_match, label: string, href: string) => {
|
||||
const decodedHref = href
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll(''', "'")
|
||||
@@ -30,13 +38,17 @@ function inlineMarkdown(value: string) {
|
||||
return safe
|
||||
? `<a href="${escapeHtml(safe)}" rel="nofollow ugc noopener noreferrer" target="_blank">${label}</a>`
|
||||
: label;
|
||||
});
|
||||
},
|
||||
);
|
||||
output = output
|
||||
.replace(/\*\*([^*\r\n]+)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/__([^_\r\n]+)__/g, '<strong>$1</strong>')
|
||||
.replace(/(^|[^\w])\*([^*\r\n]+)\*/g, '$1<em>$2</em>')
|
||||
.replace(/(^|[^\w])_([^_\r\n]+)_/g, '$1<em>$2</em>');
|
||||
return output.replace(/\u0000CODE(\d+)\u0000/g, (_match, index: string) => code[Number(index)] ?? '');
|
||||
return output.replace(
|
||||
/\u0000CODE(\d+)\u0000/g,
|
||||
(_match, index: string) => code[Number(index)] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,7 +57,10 @@ function inlineMarkdown(value: string) {
|
||||
* nofollow, and opener isolation attributes.
|
||||
*/
|
||||
export function renderUserMarkdown(markdown: unknown): string {
|
||||
const input = String(markdown ?? '').normalize('NFKC').replace(/\r\n?/g, '\n').trim();
|
||||
const input = String(markdown ?? '')
|
||||
.normalize('NFKC')
|
||||
.replace(/\r\n?/g, '\n')
|
||||
.trim();
|
||||
if (!input) return '';
|
||||
const lines = input.split('\n');
|
||||
const output: string[] = [];
|
||||
@@ -88,14 +103,18 @@ export function renderUserMarkdown(markdown: unknown): string {
|
||||
list = nextList;
|
||||
output.push(`<${list}>`);
|
||||
}
|
||||
output.push(`<li>${inlineMarkdown((unordered ?? ordered)?.[1] ?? '')}</li>`);
|
||||
output.push(
|
||||
`<li>${inlineMarkdown((unordered ?? ordered)?.[1] ?? '')}</li>`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const quote = /^>\s?(.+)$/.exec(trimmed);
|
||||
if (quote) {
|
||||
closeParagraph();
|
||||
closeList();
|
||||
output.push(`<blockquote><p>${inlineMarkdown(quote[1])}</p></blockquote>`);
|
||||
output.push(
|
||||
`<blockquote><p>${inlineMarkdown(quote[1])}</p></blockquote>`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
closeList();
|
||||
@@ -106,9 +125,16 @@ export function renderUserMarkdown(markdown: unknown): string {
|
||||
return output.join('');
|
||||
}
|
||||
|
||||
export function normalizeUserMarkdown(markdown: unknown, maxCharacters: number) {
|
||||
const normalized = String(markdown ?? '').normalize('NFKC').replace(/\r\n?/g, '\n').trim();
|
||||
export function normalizeUserMarkdown(
|
||||
markdown: unknown,
|
||||
maxCharacters: number,
|
||||
) {
|
||||
const normalized = String(markdown ?? '')
|
||||
.normalize('NFKC')
|
||||
.replace(/\r\n?/g, '\n')
|
||||
.trim();
|
||||
if (Array.from(normalized).length > maxCharacters) return null;
|
||||
if (/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/u.test(normalized)) return null;
|
||||
if (/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/u.test(normalized))
|
||||
return null;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
@@ -1,67 +1,106 @@
|
||||
import { AVATAR_PRESETS } from '$lib/server/avatars';
|
||||
import { emailDeliveryConfigured } from '$lib/server/auth/mail';
|
||||
import { canRemoveAuthenticationMethod, getAuthenticationMethods } from '$lib/server/auth/methods';
|
||||
import {
|
||||
canRemoveAuthenticationMethod,
|
||||
getAuthenticationMethods,
|
||||
} from '$lib/server/auth/methods';
|
||||
import type { Database } from '@tursodatabase/database';
|
||||
|
||||
export async function loadAccountControlData(
|
||||
database: Database,
|
||||
userId: string,
|
||||
requestedPanel: string | null = null
|
||||
requestedPanel: string | null = null,
|
||||
) {
|
||||
const [connection, emailIdentity, passkeys, authenticationMethods, notifications, notificationUnread] =
|
||||
await Promise.all([
|
||||
(await database.prepare(`SELECT users.discord_id,users.login_username,users.password_hash,
|
||||
const [
|
||||
connection,
|
||||
emailIdentity,
|
||||
passkeys,
|
||||
authenticationMethods,
|
||||
notifications,
|
||||
notificationUnread,
|
||||
] = await Promise.all([
|
||||
(
|
||||
await database.prepare(`SELECT users.discord_id,users.login_username,users.password_hash,
|
||||
discord_profiles.username AS discord_username,discord_profiles.display_name,
|
||||
discord_profiles.avatar_hash
|
||||
FROM users
|
||||
LEFT JOIN discord_profiles ON discord_profiles.user_id=users.id
|
||||
WHERE users.id=?`)).get(userId),
|
||||
(await database.prepare('SELECT email,verified_at FROM user_emails WHERE user_id=?')).get(userId),
|
||||
(await database.prepare(`SELECT id,name,device_type,backed_up,last_used_at,created_at
|
||||
FROM passkeys WHERE user_id=? ORDER BY created_at DESC`)).all(userId),
|
||||
WHERE users.id=?`)
|
||||
).get(userId),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT email,verified_at FROM user_emails WHERE user_id=?',
|
||||
)
|
||||
).get(userId),
|
||||
(
|
||||
await database.prepare(`SELECT id,name,device_type,backed_up,last_used_at,created_at
|
||||
FROM passkeys WHERE user_id=? ORDER BY created_at DESC`)
|
||||
).all(userId),
|
||||
getAuthenticationMethods(database, userId),
|
||||
(await database.prepare(`SELECT n.id,n.category,n.source,n.title,n.body,n.link,n.mandatory,
|
||||
(
|
||||
await database.prepare(`SELECT n.id,n.category,n.source,n.title,n.body,n.link,n.mandatory,
|
||||
n.created_at,n.read_at
|
||||
FROM notifications n
|
||||
LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category
|
||||
WHERE n.user_id=? AND n.read_at IS NULL
|
||||
AND (n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)
|
||||
ORDER BY n.created_at DESC LIMIT 5`)).all(userId),
|
||||
(await database.prepare(`SELECT COUNT(*) AS count FROM notifications n
|
||||
ORDER BY n.created_at DESC LIMIT 5`)
|
||||
).all(userId),
|
||||
(
|
||||
await database.prepare(`SELECT COUNT(*) AS count FROM notifications n
|
||||
LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category
|
||||
WHERE n.user_id=? AND n.read_at IS NULL
|
||||
AND (n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)`)).get(userId)
|
||||
AND (n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)`)
|
||||
).get(userId),
|
||||
]);
|
||||
return {
|
||||
initialPanel: ['identity', 'access', 'account'].includes(String(requestedPanel))
|
||||
initialPanel: ['identity', 'access', 'account'].includes(
|
||||
String(requestedPanel),
|
||||
)
|
||||
? String(requestedPanel)
|
||||
: 'identity',
|
||||
loginUsername: connection?.login_username ? String(connection.login_username) : null,
|
||||
hasLocalCredentials: typeof connection?.password_hash === 'string'
|
||||
&& typeof connection?.login_username === 'string',
|
||||
discordConnection: connection?.discord_id ? {
|
||||
loginUsername: connection?.login_username
|
||||
? String(connection.login_username)
|
||||
: null,
|
||||
hasLocalCredentials:
|
||||
typeof connection?.password_hash === 'string' &&
|
||||
typeof connection?.login_username === 'string',
|
||||
discordConnection: connection?.discord_id
|
||||
? {
|
||||
id: String(connection.discord_id),
|
||||
username: connection.discord_username ? String(connection.discord_username) : null,
|
||||
displayName: connection.display_name ? String(connection.display_name) : null,
|
||||
avatarHash: connection.avatar_hash ? String(connection.avatar_hash) : null,
|
||||
canUnlink: canRemoveAuthenticationMethod(authenticationMethods, 'discord')
|
||||
} : null,
|
||||
emailIdentity: emailIdentity ? {
|
||||
username: connection.discord_username
|
||||
? String(connection.discord_username)
|
||||
: null,
|
||||
displayName: connection.display_name
|
||||
? String(connection.display_name)
|
||||
: null,
|
||||
avatarHash: connection.avatar_hash
|
||||
? String(connection.avatar_hash)
|
||||
: null,
|
||||
canUnlink: canRemoveAuthenticationMethod(
|
||||
authenticationMethods,
|
||||
'discord',
|
||||
),
|
||||
}
|
||||
: null,
|
||||
emailIdentity: emailIdentity
|
||||
? {
|
||||
email: String(emailIdentity.email),
|
||||
verifiedAt: String(emailIdentity.verified_at)
|
||||
} : null,
|
||||
verifiedAt: String(emailIdentity.verified_at),
|
||||
}
|
||||
: null,
|
||||
passkeys: passkeys.map((passkey) => ({
|
||||
id: String(passkey.id),
|
||||
name: String(passkey.name),
|
||||
deviceType: String(passkey.device_type),
|
||||
backedUp: Boolean(passkey.backed_up),
|
||||
lastUsedAt: passkey.last_used_at ? String(passkey.last_used_at) : null,
|
||||
createdAt: String(passkey.created_at)
|
||||
createdAt: String(passkey.created_at),
|
||||
})),
|
||||
authenticationMethods,
|
||||
emailDeliveryConfigured: emailDeliveryConfigured(),
|
||||
avatarPresets: AVATAR_PRESETS,
|
||||
notifications,
|
||||
notificationUnread: Number(notificationUnread?.count ?? 0)
|
||||
notificationUnread: Number(notificationUnread?.count ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { connect } from '@tursodatabase/database';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
createAvailableDisplayName,
|
||||
displayNameIsAvailable
|
||||
displayNameIsAvailable,
|
||||
} from './agent-profiles.js';
|
||||
import { displayNameKey } from '../agents.js';
|
||||
|
||||
@@ -15,11 +15,14 @@ describe('public profile identity allocation', () => {
|
||||
display_name_key TEXT UNIQUE,
|
||||
public_slug TEXT
|
||||
)`);
|
||||
await (await database.prepare(`INSERT INTO user_profiles
|
||||
(user_id,display_name,display_name_key) VALUES(?,?,?)`))
|
||||
.run('existing', 'Agent 47', displayNameKey('Agent 47'));
|
||||
await (
|
||||
await database.prepare(`INSERT INTO user_profiles
|
||||
(user_id,display_name,display_name_key) VALUES(?,?,?)`)
|
||||
).run('existing', 'Agent 47', displayNameKey('Agent 47'));
|
||||
|
||||
expect(await createAvailableDisplayName(database, 'Agent', 'new-user', true)).toBe('Agent 48');
|
||||
expect(
|
||||
await createAvailableDisplayName(database, 'Agent', 'new-user', true),
|
||||
).toBe('Agent 48');
|
||||
});
|
||||
|
||||
it('compares display names through the normalized Unicode key', async () => {
|
||||
@@ -30,11 +33,14 @@ describe('public profile identity allocation', () => {
|
||||
display_name_key TEXT UNIQUE,
|
||||
public_slug TEXT
|
||||
)`);
|
||||
await (await database.prepare(`INSERT INTO user_profiles
|
||||
(user_id,display_name,display_name_key) VALUES(?,?,?)`))
|
||||
.run('existing', 'Élite', displayNameKey('Élite'));
|
||||
await (
|
||||
await database.prepare(`INSERT INTO user_profiles
|
||||
(user_id,display_name,display_name_key) VALUES(?,?,?)`)
|
||||
).run('existing', 'Élite', displayNameKey('Élite'));
|
||||
|
||||
expect(await displayNameIsAvailable(database, 'E\u0301lite')).toBe(false);
|
||||
expect(await displayNameIsAvailable(database, 'Élite', 'existing')).toBe(true);
|
||||
expect(await displayNameIsAvailable(database, 'Élite', 'existing')).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,17 +2,19 @@ import type { Database } from '@tursodatabase/database';
|
||||
import {
|
||||
displayNameKey,
|
||||
newAgentDisplayName,
|
||||
publicProfileSlugBase
|
||||
publicProfileSlugBase,
|
||||
} from '$lib/agents';
|
||||
|
||||
export async function displayNameIsAvailable(
|
||||
database: Database,
|
||||
displayName: string,
|
||||
exceptUserId = ''
|
||||
exceptUserId = '',
|
||||
): Promise<boolean> {
|
||||
const row = await (await database.prepare(
|
||||
'SELECT user_id FROM user_profiles WHERE display_name_key=? AND user_id<>?'
|
||||
)).get(displayNameKey(displayName), exceptUserId);
|
||||
const row = await (
|
||||
await database.prepare(
|
||||
'SELECT user_id FROM user_profiles WHERE display_name_key=? AND user_id<>?',
|
||||
)
|
||||
).get(displayNameKey(displayName), exceptUserId);
|
||||
return !row;
|
||||
}
|
||||
|
||||
@@ -20,18 +22,25 @@ export async function createAvailableDisplayName(
|
||||
database: Database,
|
||||
preferredName: string,
|
||||
userId: string,
|
||||
placeholder = false
|
||||
placeholder = false,
|
||||
): Promise<string> {
|
||||
if (!placeholder && await displayNameIsAvailable(database, preferredName)) return preferredName;
|
||||
if (!placeholder && (await displayNameIsAvailable(database, preferredName)))
|
||||
return preferredName;
|
||||
if (placeholder) {
|
||||
const names = await (await database.prepare(
|
||||
"SELECT display_name FROM user_profiles WHERE display_name LIKE 'Agent %'"
|
||||
)).all();
|
||||
let sequence = names.reduce((highest, row) => {
|
||||
const names = await (
|
||||
await database.prepare(
|
||||
"SELECT display_name FROM user_profiles WHERE display_name LIKE 'Agent %'",
|
||||
)
|
||||
).all();
|
||||
let sequence =
|
||||
names.reduce((highest, row) => {
|
||||
const match = /^Agent ([1-9]\d*)$/.exec(String(row.display_name));
|
||||
return match ? Math.max(highest, Number(match[1])) : highest;
|
||||
}, 0) + 1;
|
||||
while (!(await displayNameIsAvailable(database, newAgentDisplayName(sequence)))) sequence += 1;
|
||||
while (
|
||||
!(await displayNameIsAvailable(database, newAgentDisplayName(sequence)))
|
||||
)
|
||||
sequence += 1;
|
||||
return newAgentDisplayName(sequence);
|
||||
}
|
||||
for (let suffixLength = 5; suffixLength <= 12; suffixLength += 1) {
|
||||
@@ -44,12 +53,14 @@ export async function createAvailableDisplayName(
|
||||
export async function createPublicProfileSlug(
|
||||
database: Database,
|
||||
displayName: string,
|
||||
userId: string
|
||||
userId: string,
|
||||
): Promise<string> {
|
||||
const base = publicProfileSlugBase(displayName, userId);
|
||||
const existing = await (await database.prepare(
|
||||
'SELECT user_id FROM user_profiles WHERE public_slug=?'
|
||||
)).get(base);
|
||||
const existing = await (
|
||||
await database.prepare(
|
||||
'SELECT user_id FROM user_profiles WHERE public_slug=?',
|
||||
)
|
||||
).get(base);
|
||||
if (!existing || String(existing.user_id) === userId) return base;
|
||||
return `${base.slice(0, 55).replace(/-+$/g, '')}~${userId.replaceAll('-', '').slice(0, 8).toLowerCase()}`;
|
||||
}
|
||||
@@ -59,32 +70,53 @@ export async function insertUserProfile(
|
||||
userId: string,
|
||||
preferredName: string,
|
||||
updatedAt: string,
|
||||
options: { placeholder?: boolean; resolveCollision?: boolean } = {}
|
||||
options: { placeholder?: boolean; resolveCollision?: boolean } = {},
|
||||
): Promise<{ displayName: string; publicSlug: string }> {
|
||||
const displayName = options.placeholder || options.resolveCollision
|
||||
? await createAvailableDisplayName(database, preferredName, userId, Boolean(options.placeholder))
|
||||
const displayName =
|
||||
options.placeholder || options.resolveCollision
|
||||
? await createAvailableDisplayName(
|
||||
database,
|
||||
preferredName,
|
||||
userId,
|
||||
Boolean(options.placeholder),
|
||||
)
|
||||
: preferredName;
|
||||
const publicSlug = await createPublicProfileSlug(database, displayName, userId);
|
||||
await (await database.prepare(`INSERT INTO user_profiles
|
||||
(user_id,display_name,display_name_key,public_slug,updated_at) VALUES(?,?,?,?,?)`))
|
||||
.run(userId, displayName, displayNameKey(displayName), publicSlug, updatedAt);
|
||||
const publicSlug = await createPublicProfileSlug(
|
||||
database,
|
||||
displayName,
|
||||
userId,
|
||||
);
|
||||
await (
|
||||
await database.prepare(`INSERT INTO user_profiles
|
||||
(user_id,display_name,display_name_key,public_slug,updated_at) VALUES(?,?,?,?,?)`)
|
||||
).run(
|
||||
userId,
|
||||
displayName,
|
||||
displayNameKey(displayName),
|
||||
publicSlug,
|
||||
updatedAt,
|
||||
);
|
||||
return { displayName, publicSlug };
|
||||
}
|
||||
|
||||
export async function resolveAgentProfile(
|
||||
database: Database,
|
||||
identifier: string
|
||||
identifier: string,
|
||||
): Promise<{ id: string; publicSlug: string; displayName: string } | null> {
|
||||
const row = await (await database.prepare(`SELECT users.id,
|
||||
const row = await (
|
||||
await database.prepare(`SELECT users.id,
|
||||
COALESCE(profile.public_slug,users.id) AS public_slug,
|
||||
COALESCE(profile.display_name,users.username) AS display_name
|
||||
FROM users LEFT JOIN user_profiles profile ON profile.user_id=users.id
|
||||
WHERE users.id=? OR profile.public_slug=?
|
||||
ORDER BY CASE WHEN profile.public_slug=? THEN 0 ELSE 1 END
|
||||
LIMIT 1`)).get(identifier, identifier, identifier);
|
||||
return row ? {
|
||||
LIMIT 1`)
|
||||
).get(identifier, identifier, identifier);
|
||||
return row
|
||||
? {
|
||||
id: String(row.id),
|
||||
publicSlug: String(row.public_slug),
|
||||
displayName: String(row.display_name)
|
||||
} : null;
|
||||
displayName: String(row.display_name),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
AI_CALCULATION_STAGE_OPERATORS,
|
||||
AI_SKILL_CALCULATION_STAGES,
|
||||
type AiCalculationAuthoringRequest
|
||||
type AiCalculationAuthoringRequest,
|
||||
} from '$lib/ai/calculation-authoring';
|
||||
import type { AiMechanicAttribute } from './mechanic-authoring-context';
|
||||
|
||||
@@ -46,14 +46,15 @@ export function buildAiCalculationAuthoringInput(input: {
|
||||
curves: ['none', 'power', 'smoothstep'],
|
||||
skillStages: AI_SKILL_CALCULATION_STAGES,
|
||||
skillProfileOperators: ['add', 'multiply'],
|
||||
attributes: input.attributes
|
||||
attributes: input.attributes,
|
||||
},
|
||||
constraints: {
|
||||
stageCount: { min: 2, max: 30 },
|
||||
profileWeight: { min: -100, max: 100 },
|
||||
softAndHardCaps: 'null or non-negative; hard cap cannot be below soft cap',
|
||||
softAndHardCaps:
|
||||
'null or non-negative; hard cap cannot be below soft cap',
|
||||
maxWeaponProfiles: 2_000,
|
||||
maxSkillProfiles: 2_000
|
||||
}
|
||||
maxSkillProfiles: 2_000,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user