feat: add community publishing and news syndication
This commit is contained in:
+5
-1
@@ -38,4 +38,8 @@ vite.config.js.timestamp-*
|
|||||||
vite.config.ts.timestamp-*
|
vite.config.ts.timestamp-*
|
||||||
|
|
||||||
_img/
|
_img/
|
||||||
tmp/
|
tmp/
|
||||||
|
|
||||||
|
# Python caches
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
npx lint-staged
|
||||||
@@ -2,29 +2,30 @@
|
|||||||
target: application route system
|
target: application route system
|
||||||
total_score: 28
|
total_score: 28
|
||||||
max_score: 40
|
max_score: 40
|
||||||
na_heuristics:
|
na_heuristics:
|
||||||
p0_count: 0
|
p0_count: 0
|
||||||
p1_count: 2
|
p1_count: 2
|
||||||
timestamp: 2026-07-26T15-49-23Z
|
timestamp: 2026-07-26T15-49-23Z
|
||||||
slug: src-routes
|
slug: src-routes
|
||||||
---
|
---
|
||||||
|
|
||||||
# SHD.build route-system critique
|
# SHD.build route-system critique
|
||||||
|
|
||||||
## Design Health Score
|
## Design Health Score
|
||||||
|
|
||||||
| # | Heuristic | Score | Key issue |
|
| # | Heuristic | Score | Key issue |
|
||||||
|---|---|---:|---|
|
| --------- | ------------------------------- | --------: | ------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| 1 | Visibility of system status | 4 | ISAC exposes environment, access, mode, active navigation, and filter state clearly. |
|
| 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. |
|
| 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. |
|
| 3 | User control and freedom | 3 | Navigation and cancellation patterns are strong; empty destinations and filter clearing are less explicit. |
|
||||||
| 4 | Consistency and standards | 3 | Shared patterns are cohesive, but live search plus Search, static-looking KPI links, and Edit-only rows diverge. |
|
| 4 | Consistency and standards | 3 | Shared patterns are cohesive, but live search plus Search, static-looking KPI links, and Edit-only rows diverge. |
|
||||||
| 5 | Error prevention | 3 | Guardrails are extensive, but Add/Edit consequence language is not visible before entry. |
|
| 5 | Error prevention | 3 | Guardrails are extensive, but Add/Edit consequence language is not visible before entry. |
|
||||||
| 6 | Recognition rather than recall | 2 | Seventeen ISAC destinations, code labels, and visually identical category cards create scanning burden. |
|
| 6 | Recognition rather than recall | 2 | Seventeen ISAC destinations, code labels, and visually identical category cards create scanning burden. |
|
||||||
| 7 | Flexibility and efficiency | 3 | Search, filtering, page sizes, and grouped navigation help experts; one-page registries expose unnecessary controls. |
|
| 7 | Flexibility and efficiency | 3 | Search, filtering, page sizes, and grouped navigation help experts; one-page registries expose unnecessary controls. |
|
||||||
| 8 | Aesthetic and minimalist design | 2 | The system is disciplined, but cinematic hero scale and equally weighted bordered blocks compete with the task. |
|
| 8 | Aesthetic and minimalist design | 2 | The system is disciplined, but cinematic hero scale and equally weighted bordered blocks compete with the task. |
|
||||||
| 9 | Error recovery | 3 | The application has designed error and preservation patterns, though recovery was not exercised in the inspected states. |
|
| 9 | Error recovery | 3 | The application has designed error and preservation patterns, though recovery was not exercised in the inspected states. |
|
||||||
| 10 | Help and documentation | 2 | Microcopy exists, but ISAC terms, permissions, and proposal consequences lack contextual help. |
|
| 10 | Help and documentation | 2 | Microcopy exists, but ISAC terms, permissions, and proposal consequences lack contextual help. |
|
||||||
| **Total** | | **28/40** | **Good foundation; focused hierarchy and disclosure work needed.** |
|
| **Total** | | **28/40** | **Good foundation; focused hierarchy and disclosure work needed.** |
|
||||||
|
|
||||||
## Design Specificity Verdict
|
## Design Specificity Verdict
|
||||||
|
|
||||||
@@ -36,11 +37,12 @@ blocks flatten into a generic dark administrative grid.
|
|||||||
|
|
||||||
The deterministic scan found seven source findings across `src/routes`: one
|
The deterministic scan found seven source findings across `src/routes`: one
|
||||||
warning for the two-pixel semantic notification rail in
|
warning for the two-pixel semantic notification rail in
|
||||||
`src/routes/account/+page.svelte`, and six advisory colors outside the documented
|
`src/routes/account/+page.svelte`, and six advisory colors outside the
|
||||||
palette in the homepage and account route. The rail is largely a detector false
|
documented palette in the homepage and account route. The rail is largely a
|
||||||
positive because it conveys semantic message state, but its colors should still
|
detector false positive because it conveys semantic message state, but its
|
||||||
come from named tokens. The homepage build colors are data-like values rather
|
colors should still come from named tokens. The homepage build colors are
|
||||||
than structural CSS, yet they also bypass the maintained palette.
|
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.
|
Mutable browser injection was unavailable, so no visual overlay is claimed.
|
||||||
Fresh Chrome tabs, screenshots, DOM snapshots, and computed styles instead
|
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
|
The Attributes screen simultaneously presents 17 sidebar destinations, four
|
||||||
system-state blocks, four KPI filters, search, pagination, row-size controls,
|
system-state blocks, four KPI filters, search, pagination, row-size controls,
|
||||||
six table columns, and repeated Edit actions. Experienced operators can scan
|
six table columns, and repeated Edit actions. Experienced operators can scan it,
|
||||||
it, but contributors and new Oversight users must decode the whole information
|
but contributors and new Oversight users must decode the whole information
|
||||||
architecture before acting.
|
architecture before acting.
|
||||||
|
|
||||||
**Fix:** make sidebar groups collapsible, prioritize modules by role and recent
|
**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
|
### P2 — Empty registries promise the same value as populated registries
|
||||||
|
|
||||||
Four of eight Database cards show zero records and “Awaiting contributions,”
|
Four of eight Database cards show zero records and “Awaiting contributions,” but
|
||||||
but look and behave like full destinations. That creates repeated anticipation
|
look and behave like full destinations. That creates repeated anticipation
|
||||||
followed by a low-value endpoint.
|
followed by a low-value endpoint.
|
||||||
|
|
||||||
**Fix:** give empty registries an explicit empty-state anatomy with expected
|
**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
|
### P2 — Functional contrast and interaction grammar remain uneven
|
||||||
|
|
||||||
Muted database descriptions, inactive navigation, unit sublabels, and
|
Muted database descriptions, inactive navigation, unit sublabels, and pagination
|
||||||
pagination text recede heavily. KPI filters resemble static metrics, table rows
|
text recede heavily. KPI filters resemble static metrics, table rows look
|
||||||
look selectable while only the small Edit action works, and a Search button
|
selectable while only the small Edit action works, and a Search button remains
|
||||||
remains beside an input that searches as the user types.
|
beside an input that searches as the user types.
|
||||||
|
|
||||||
**Fix:** reserve the dimmest tone for nonessential metadata, raise informative
|
**Fix:** reserve the dimmest tone for nonessential metadata, raise informative
|
||||||
copy and available navigation one contrast step, make KPI filter state
|
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
|
The Database hero creates a confident, premium entry peak. Populated cards
|
||||||
promise useful intelligence, but visually equal empty categories produce a
|
promise useful intelligence, but visually equal empty categories produce a
|
||||||
disappointment valley. ISAC's “Online / Authorized / Admin” state reassures
|
disappointment valley. ISAC's “Online / Authorized / Admin” state reassures
|
||||||
operators, then the uniform density creates fatigue with little calm focus.
|
operators, then the uniform density creates fatigue with little calm focus. The
|
||||||
The most important missing reassurance is a plain statement of what Add/Edit
|
most important missing reassurance is a plain statement of what Add/Edit will do
|
||||||
will do to live data.
|
to live data.
|
||||||
|
|
||||||
## Persona Red Flags
|
## Persona Red Flags
|
||||||
|
|
||||||
|
|||||||
+220
-22
@@ -8,133 +8,331 @@
|
|||||||
"role": "neutral",
|
"role": "neutral",
|
||||||
"displayName": "Operations Black",
|
"displayName": "Operations Black",
|
||||||
"canonical": "#0a0c0d",
|
"canonical": "#0a0c0d",
|
||||||
"tonalRamp": ["#050606", "#0a0c0d", "#171a1b", "#303536", "#555c5e", "#858b8d", "#c1c4c2", "#f0f0eb"]
|
"tonalRamp": [
|
||||||
|
"#050606",
|
||||||
|
"#0a0c0d",
|
||||||
|
"#171a1b",
|
||||||
|
"#303536",
|
||||||
|
"#555c5e",
|
||||||
|
"#858b8d",
|
||||||
|
"#c1c4c2",
|
||||||
|
"#f0f0eb"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"terminal-black": {
|
"terminal-black": {
|
||||||
"role": "neutral",
|
"role": "neutral",
|
||||||
"displayName": "Terminal Black",
|
"displayName": "Terminal Black",
|
||||||
"canonical": "#0d0f10",
|
"canonical": "#0d0f10",
|
||||||
"tonalRamp": ["#050606", "#0d0f10", "#1b1e1f", "#34393a", "#596062", "#888f91", "#c3c7c6", "#f1f2ee"]
|
"tonalRamp": [
|
||||||
|
"#050606",
|
||||||
|
"#0d0f10",
|
||||||
|
"#1b1e1f",
|
||||||
|
"#34393a",
|
||||||
|
"#596062",
|
||||||
|
"#888f91",
|
||||||
|
"#c3c7c6",
|
||||||
|
"#f1f2ee"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"terminal-surface": {
|
"terminal-surface": {
|
||||||
"role": "neutral",
|
"role": "neutral",
|
||||||
"displayName": "Terminal Surface",
|
"displayName": "Terminal Surface",
|
||||||
"canonical": "#111415",
|
"canonical": "#111415",
|
||||||
"tonalRamp": ["#070808", "#111415", "#202425", "#393e3f", "#5f6668", "#8d9496", "#c6cac9", "#f2f3ef"]
|
"tonalRamp": [
|
||||||
|
"#070808",
|
||||||
|
"#111415",
|
||||||
|
"#202425",
|
||||||
|
"#393e3f",
|
||||||
|
"#5f6668",
|
||||||
|
"#8d9496",
|
||||||
|
"#c6cac9",
|
||||||
|
"#f2f3ef"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"terminal-graphite": {
|
"terminal-graphite": {
|
||||||
"role": "neutral",
|
"role": "neutral",
|
||||||
"displayName": "Terminal Graphite",
|
"displayName": "Terminal Graphite",
|
||||||
"canonical": "#141718",
|
"canonical": "#141718",
|
||||||
"tonalRamp": ["#080a0a", "#111415", "#202425", "#343a3b", "#565e60", "#858d8f", "#c0c5c3", "#f0f1ed"]
|
"tonalRamp": [
|
||||||
|
"#080a0a",
|
||||||
|
"#111415",
|
||||||
|
"#202425",
|
||||||
|
"#343a3b",
|
||||||
|
"#565e60",
|
||||||
|
"#858d8f",
|
||||||
|
"#c0c5c3",
|
||||||
|
"#f0f1ed"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"active-graphite": {
|
"active-graphite": {
|
||||||
"role": "neutral",
|
"role": "neutral",
|
||||||
"displayName": "Active Graphite",
|
"displayName": "Active Graphite",
|
||||||
"canonical": "#15191a",
|
"canonical": "#15191a",
|
||||||
"tonalRamp": ["#080a0a", "#15191a", "#252a2b", "#3e4445", "#626a6c", "#909799", "#c8cccb", "#f2f3f0"]
|
"tonalRamp": [
|
||||||
|
"#080a0a",
|
||||||
|
"#15191a",
|
||||||
|
"#252a2b",
|
||||||
|
"#3e4445",
|
||||||
|
"#626a6c",
|
||||||
|
"#909799",
|
||||||
|
"#c8cccb",
|
||||||
|
"#f2f3f0"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"modeled-data-surface": {
|
"modeled-data-surface": {
|
||||||
"role": "neutral",
|
"role": "neutral",
|
||||||
"displayName": "Modeled Data Surface",
|
"displayName": "Modeled Data Surface",
|
||||||
"canonical": "#0d1213",
|
"canonical": "#0d1213",
|
||||||
"tonalRamp": ["#050708", "#0d1213", "#1c2527", "#334043", "#526165", "#7d8b8e", "#bac2c2", "#eff1ef"]
|
"tonalRamp": [
|
||||||
|
"#050708",
|
||||||
|
"#0d1213",
|
||||||
|
"#1c2527",
|
||||||
|
"#334043",
|
||||||
|
"#526165",
|
||||||
|
"#7d8b8e",
|
||||||
|
"#bac2c2",
|
||||||
|
"#eff1ef"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"structural-line": {
|
"structural-line": {
|
||||||
"role": "neutral",
|
"role": "neutral",
|
||||||
"displayName": "Structural Line",
|
"displayName": "Structural Line",
|
||||||
"canonical": "#303536",
|
"canonical": "#303536",
|
||||||
"tonalRamp": ["#0d0f10", "#1b1f20", "#303536", "#4b5152", "#6a7274", "#92999b", "#c3c8c7", "#f0f1ee"]
|
"tonalRamp": [
|
||||||
|
"#0d0f10",
|
||||||
|
"#1b1f20",
|
||||||
|
"#303536",
|
||||||
|
"#4b5152",
|
||||||
|
"#6a7274",
|
||||||
|
"#92999b",
|
||||||
|
"#c3c8c7",
|
||||||
|
"#f0f1ee"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"structural-line-soft": {
|
"structural-line-soft": {
|
||||||
"role": "neutral",
|
"role": "neutral",
|
||||||
"displayName": "Structural Line Soft",
|
"displayName": "Structural Line Soft",
|
||||||
"canonical": "#272c2d",
|
"canonical": "#272c2d",
|
||||||
"tonalRamp": ["#0b0d0d", "#181b1c", "#272c2d", "#43494a", "#656d6f", "#8e9597", "#c2c6c5", "#eff0ed"]
|
"tonalRamp": [
|
||||||
|
"#0b0d0d",
|
||||||
|
"#181b1c",
|
||||||
|
"#272c2d",
|
||||||
|
"#43494a",
|
||||||
|
"#656d6f",
|
||||||
|
"#8e9597",
|
||||||
|
"#c2c6c5",
|
||||||
|
"#eff0ed"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"structural-line-strong": {
|
"structural-line-strong": {
|
||||||
"role": "neutral",
|
"role": "neutral",
|
||||||
"displayName": "Structural Line Strong",
|
"displayName": "Structural Line Strong",
|
||||||
"canonical": "#424849",
|
"canonical": "#424849",
|
||||||
"tonalRamp": ["#111415", "#252a2b", "#424849", "#5d6567", "#7c8587", "#a0a7a8", "#c9cdcc", "#f1f2ef"]
|
"tonalRamp": [
|
||||||
|
"#111415",
|
||||||
|
"#252a2b",
|
||||||
|
"#424849",
|
||||||
|
"#5d6567",
|
||||||
|
"#7c8587",
|
||||||
|
"#a0a7a8",
|
||||||
|
"#c9cdcc",
|
||||||
|
"#f1f2ef"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"modeled-data-line": {
|
"modeled-data-line": {
|
||||||
"role": "neutral",
|
"role": "neutral",
|
||||||
"displayName": "Modeled Data Line",
|
"displayName": "Modeled Data Line",
|
||||||
"canonical": "#3b4648",
|
"canonical": "#3b4648",
|
||||||
"tonalRamp": ["#0e1314", "#20292b", "#3b4648", "#566366", "#758184", "#9aa3a5", "#c6cbca", "#f0f2ef"]
|
"tonalRamp": [
|
||||||
|
"#0e1314",
|
||||||
|
"#20292b",
|
||||||
|
"#3b4648",
|
||||||
|
"#566366",
|
||||||
|
"#758184",
|
||||||
|
"#9aa3a5",
|
||||||
|
"#c6cbca",
|
||||||
|
"#f0f2ef"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"field-manual-white": {
|
"field-manual-white": {
|
||||||
"role": "neutral",
|
"role": "neutral",
|
||||||
"displayName": "Field Manual White",
|
"displayName": "Field Manual White",
|
||||||
"canonical": "#ecebe5",
|
"canonical": "#ecebe5",
|
||||||
"tonalRamp": ["#252522", "#44433f", "#66655f", "#898780", "#aaa8a1", "#cac8c1", "#ecebe5", "#faf9f5"]
|
"tonalRamp": [
|
||||||
|
"#252522",
|
||||||
|
"#44433f",
|
||||||
|
"#66655f",
|
||||||
|
"#898780",
|
||||||
|
"#aaa8a1",
|
||||||
|
"#cac8c1",
|
||||||
|
"#ecebe5",
|
||||||
|
"#faf9f5"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"telemetry-muted": {
|
"telemetry-muted": {
|
||||||
"role": "neutral",
|
"role": "neutral",
|
||||||
"displayName": "Telemetry Muted",
|
"displayName": "Telemetry Muted",
|
||||||
"canonical": "#858b8d",
|
"canonical": "#858b8d",
|
||||||
"tonalRamp": ["#151718", "#292d2e", "#414647", "#5e6466", "#858b8d", "#a6abad", "#cbd0cf", "#f1f2ef"]
|
"tonalRamp": [
|
||||||
|
"#151718",
|
||||||
|
"#292d2e",
|
||||||
|
"#414647",
|
||||||
|
"#5e6466",
|
||||||
|
"#858b8d",
|
||||||
|
"#a6abad",
|
||||||
|
"#cbd0cf",
|
||||||
|
"#f1f2ef"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"telemetry-dim": {
|
"telemetry-dim": {
|
||||||
"role": "neutral",
|
"role": "neutral",
|
||||||
"displayName": "Telemetry Dim",
|
"displayName": "Telemetry Dim",
|
||||||
"canonical": "#5d6567",
|
"canonical": "#5d6567",
|
||||||
"tonalRamp": ["#111314", "#222627", "#373c3d", "#4b5254", "#5d6567", "#858d8f", "#bcc1c0", "#eef0ed"]
|
"tonalRamp": [
|
||||||
|
"#111314",
|
||||||
|
"#222627",
|
||||||
|
"#373c3d",
|
||||||
|
"#4b5254",
|
||||||
|
"#5d6567",
|
||||||
|
"#858d8f",
|
||||||
|
"#bcc1c0",
|
||||||
|
"#eef0ed"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"signal-orange": {
|
"signal-orange": {
|
||||||
"role": "primary",
|
"role": "primary",
|
||||||
"displayName": "Signal Orange",
|
"displayName": "Signal Orange",
|
||||||
"canonical": "#f6a821",
|
"canonical": "#f6a821",
|
||||||
"tonalRamp": ["#2b1a05", "#4c2f08", "#754a0d", "#a66c14", "#d7901c", "#f6a821", "#ffd278", "#fff1cf"]
|
"tonalRamp": [
|
||||||
|
"#2b1a05",
|
||||||
|
"#4c2f08",
|
||||||
|
"#754a0d",
|
||||||
|
"#a66c14",
|
||||||
|
"#d7901c",
|
||||||
|
"#f6a821",
|
||||||
|
"#ffd278",
|
||||||
|
"#fff1cf"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"signal-orange-hover": {
|
"signal-orange-hover": {
|
||||||
"role": "primary",
|
"role": "primary",
|
||||||
"displayName": "Signal Orange Hover",
|
"displayName": "Signal Orange Hover",
|
||||||
"canonical": "#ffc45d",
|
"canonical": "#ffc45d",
|
||||||
"tonalRamp": ["#2d1f08", "#51380d", "#785515", "#a97824", "#d99d39", "#ffc45d", "#ffdda0", "#fff3d9"]
|
"tonalRamp": [
|
||||||
|
"#2d1f08",
|
||||||
|
"#51380d",
|
||||||
|
"#785515",
|
||||||
|
"#a97824",
|
||||||
|
"#d99d39",
|
||||||
|
"#ffc45d",
|
||||||
|
"#ffdda0",
|
||||||
|
"#fff3d9"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"system-success": {
|
"system-success": {
|
||||||
"role": "secondary",
|
"role": "secondary",
|
||||||
"displayName": "System Success",
|
"displayName": "System Success",
|
||||||
"canonical": "#75c08c",
|
"canonical": "#75c08c",
|
||||||
"tonalRamp": ["#102a19", "#194328", "#275f39", "#3f7d50", "#5c9e6d", "#75c08c", "#a8dcba", "#e2f4e8"]
|
"tonalRamp": [
|
||||||
|
"#102a19",
|
||||||
|
"#194328",
|
||||||
|
"#275f39",
|
||||||
|
"#3f7d50",
|
||||||
|
"#5c9e6d",
|
||||||
|
"#75c08c",
|
||||||
|
"#a8dcba",
|
||||||
|
"#e2f4e8"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"system-danger": {
|
"system-danger": {
|
||||||
"role": "secondary",
|
"role": "secondary",
|
||||||
"displayName": "System Danger",
|
"displayName": "System Danger",
|
||||||
"canonical": "#df8069",
|
"canonical": "#df8069",
|
||||||
"tonalRamp": ["#32130e", "#542219", "#793428", "#a54a39", "#c9634f", "#df8069", "#edae9f", "#f8dfd9"]
|
"tonalRamp": [
|
||||||
|
"#32130e",
|
||||||
|
"#542219",
|
||||||
|
"#793428",
|
||||||
|
"#a54a39",
|
||||||
|
"#c9634f",
|
||||||
|
"#df8069",
|
||||||
|
"#edae9f",
|
||||||
|
"#f8dfd9"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"system-info": {
|
"system-info": {
|
||||||
"role": "secondary",
|
"role": "secondary",
|
||||||
"displayName": "System Information",
|
"displayName": "System Information",
|
||||||
"canonical": "#70b9d6",
|
"canonical": "#70b9d6",
|
||||||
"tonalRamp": ["#0d2630", "#163f50", "#245b70", "#387991", "#5599b3", "#70b9d6", "#a6d5e7", "#e1f1f7"]
|
"tonalRamp": [
|
||||||
|
"#0d2630",
|
||||||
|
"#163f50",
|
||||||
|
"#245b70",
|
||||||
|
"#387991",
|
||||||
|
"#5599b3",
|
||||||
|
"#70b9d6",
|
||||||
|
"#a6d5e7",
|
||||||
|
"#e1f1f7"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"quality-standard": {
|
"quality-standard": {
|
||||||
"role": "tertiary",
|
"role": "tertiary",
|
||||||
"displayName": "Standard Quality",
|
"displayName": "Standard Quality",
|
||||||
"canonical": "#69b66f",
|
"canonical": "#69b66f",
|
||||||
"tonalRamp": ["#0e2912", "#17431d", "#255e2b", "#397b3e", "#519a56", "#69b66f", "#9dd4a1", "#dff0e0"]
|
"tonalRamp": [
|
||||||
|
"#0e2912",
|
||||||
|
"#17431d",
|
||||||
|
"#255e2b",
|
||||||
|
"#397b3e",
|
||||||
|
"#519a56",
|
||||||
|
"#69b66f",
|
||||||
|
"#9dd4a1",
|
||||||
|
"#dff0e0"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"quality-specialized": {
|
"quality-specialized": {
|
||||||
"role": "tertiary",
|
"role": "tertiary",
|
||||||
"displayName": "Specialized Quality",
|
"displayName": "Specialized Quality",
|
||||||
"canonical": "#4f9ed8",
|
"canonical": "#4f9ed8",
|
||||||
"tonalRamp": ["#0c2435", "#123c59", "#1d567d", "#2d72a3", "#408dc4", "#4f9ed8", "#8dc5ea", "#d9edf8"]
|
"tonalRamp": [
|
||||||
|
"#0c2435",
|
||||||
|
"#123c59",
|
||||||
|
"#1d567d",
|
||||||
|
"#2d72a3",
|
||||||
|
"#408dc4",
|
||||||
|
"#4f9ed8",
|
||||||
|
"#8dc5ea",
|
||||||
|
"#d9edf8"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"quality-superior": {
|
"quality-superior": {
|
||||||
"role": "tertiary",
|
"role": "tertiary",
|
||||||
"displayName": "Superior Quality",
|
"displayName": "Superior Quality",
|
||||||
"canonical": "#9b68d4",
|
"canonical": "#9b68d4",
|
||||||
"tonalRamp": ["#21132f", "#38204f", "#522f70", "#704594", "#8959b7", "#9b68d4", "#c3a2e5", "#eee1f7"]
|
"tonalRamp": [
|
||||||
|
"#21132f",
|
||||||
|
"#38204f",
|
||||||
|
"#522f70",
|
||||||
|
"#704594",
|
||||||
|
"#8959b7",
|
||||||
|
"#9b68d4",
|
||||||
|
"#c3a2e5",
|
||||||
|
"#eee1f7"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"quality-high-end": {
|
"quality-high-end": {
|
||||||
"role": "tertiary",
|
"role": "tertiary",
|
||||||
"displayName": "High-end Quality",
|
"displayName": "High-end Quality",
|
||||||
"canonical": "#e4bd3f",
|
"canonical": "#e4bd3f",
|
||||||
"tonalRamp": ["#2d2408", "#4c3c0d", "#6f5815", "#95771f", "#bd992d", "#e4bd3f", "#f0d982", "#fbf2ce"]
|
"tonalRamp": [
|
||||||
|
"#2d2408",
|
||||||
|
"#4c3c0d",
|
||||||
|
"#6f5815",
|
||||||
|
"#95771f",
|
||||||
|
"#bd992d",
|
||||||
|
"#e4bd3f",
|
||||||
|
"#f0d982",
|
||||||
|
"#fbf2ce"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"typographyMeta": {
|
"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
|
||||||
|
}
|
||||||
Vendored
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"recommendations": ["svelte.svelte-vscode"]
|
"recommendations": ["svelte.svelte-vscode"]
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-12
@@ -8,7 +8,7 @@ web
|
|||||||
|
|
||||||
## Users
|
## 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.
|
data, accurate loadout planning, build sharing, and community guides.
|
||||||
|
|
||||||
Registered players can save and share builds, publish guides, participate in
|
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
|
## Product Purpose
|
||||||
|
|
||||||
SHD.build is an independent community intelligence platform for *The Division
|
SHD.build is an independent community intelligence platform for _The Division
|
||||||
Resurgence*. It includes a public game database, calculated loadout builder,
|
Resurgence_. It includes a public game database, calculated loadout builder,
|
||||||
shared builds, guides, news, and community participation.
|
shared builds, guides, news, and community participation.
|
||||||
|
|
||||||
Players should understand game systems, create accurate loadouts, learn from
|
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
|
Players use the site to plan characters, compare drops, reproduce in-game
|
||||||
equipment, browse community recommendations, and respond to game updates. The
|
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.
|
The principal public channels are News, Database, Builds, Guides, and Builder.
|
||||||
Account management includes authentication methods, profile identity, avatars,
|
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.
|
- The application is built with SvelteKit and Svelte.
|
||||||
- Persistent application data uses the embedded `@tursodatabase/database`
|
- Persistent application data uses the embedded `@tursodatabase/database`
|
||||||
package. Turso Cloud is not required.
|
package. Turso Cloud is not required.
|
||||||
- Development, staging, and production use distinct databases. Embedded
|
- Development, staging, and production use distinct databases. Embedded database
|
||||||
database files and uploaded assets are persistent user data and must survive
|
files and uploaded assets are persistent user data and must survive
|
||||||
deployments and additive schema migrations.
|
deployments and additive schema migrations.
|
||||||
- Authentication supports passkeys, emailed magic links, Discord OAuth, and
|
- Authentication supports passkeys, emailed magic links, Discord OAuth, and
|
||||||
optional username/password credentials. An account must always retain at
|
optional username/password credentials. An account must always retain at least
|
||||||
least one usable sign-in method.
|
one usable sign-in method.
|
||||||
- Anonymous builder drafts may use local storage. Database-backed sharing
|
- Anonymous builder drafts may use local storage. Database-backed sharing
|
||||||
requires an account.
|
requires an account.
|
||||||
- Saved builds support private, unlisted, and public visibility.
|
- 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
|
- Major data changes preserve contributor-entered data through verified backups
|
||||||
and additive migrations with minimal development-server downtime.
|
and additive migrations with minimal development-server downtime.
|
||||||
- SHD.build is an independent fan site. Ubisoft and the relevant owners retain
|
- 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.
|
associated intellectual property and assets.
|
||||||
|
|
||||||
## Brand Commitments
|
## Brand Commitments
|
||||||
|
|
||||||
The product name is `SHD.build`, with `SHD` capitalized. The product serves the
|
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,
|
Use restrained in-universe operational language such as agent, ISAC, Oversight,
|
||||||
intelligence, access, transmission, and registry when it improves comprehension.
|
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,
|
SHD.build targets WCAG 2.2 AA. Interfaces must support keyboard navigation,
|
||||||
clear focus-visible states, screen-reader semantics, sufficient contrast,
|
clear focus-visible states, screen-reader semantics, sufficient contrast,
|
||||||
reduced-motion preferences, and layouts that work from mobile screens through
|
reduced-motion preferences, and layouts that work from mobile screens through 4K
|
||||||
4K desktop displays.
|
desktop displays.
|
||||||
|
|
||||||
State and meaning cannot depend on color, icons, hover, or motion alone.
|
State and meaning cannot depend on color, icons, hover, or motion alone.
|
||||||
Controls need usable touch targets. User identity must support non-ASCII display
|
Controls need usable touch targets. User identity must support non-ASCII display
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# SHD.build
|
# SHD.build
|
||||||
|
|
||||||
A SvelteKit community application for *The Division Resurgence*. SHD.build
|
A SvelteKit community application for _The Division Resurgence_. SHD.build
|
||||||
combines a public game database, loadout builder, shared builds, guides,
|
combines a public game database, loadout builder, shared builds, guides, account
|
||||||
account management, and the ISAC data-management console.
|
management, and the ISAC data-management console.
|
||||||
|
|
||||||
See `docs/architecture.md` for the implementation model.
|
See `docs/architecture.md` for the implementation model.
|
||||||
|
|
||||||
@@ -17,13 +17,12 @@ Create or update the local database:
|
|||||||
npm run db:migrate
|
npm run db:migrate
|
||||||
```
|
```
|
||||||
|
|
||||||
The default file is `data/shd-build.db`. Copy `.env.example` to `.env` and
|
The default file is `data/shd-build.db`. Copy `.env.example` to `.env` and set a
|
||||||
set a different location if needed.
|
different location if needed.
|
||||||
|
|
||||||
Store custom avatars as metadata-free 256 × 256 WebP files. Set
|
Store custom avatars as metadata-free 256 × 256 WebP files. Set
|
||||||
`TDR_AVATAR_STORAGE_PATH` to a persistent, writable directory in production;
|
`TDR_AVATAR_STORAGE_PATH` to a persistent, writable directory in production; it
|
||||||
it defaults to `data/avatars`. The same configuration works on Windows and
|
defaults to `data/avatars`. The same configuration works on Windows and Linux.
|
||||||
Linux.
|
|
||||||
|
|
||||||
Seed the initial local accounts once:
|
Seed the initial local accounts once:
|
||||||
|
|
||||||
@@ -31,9 +30,9 @@ Seed the initial local accounts once:
|
|||||||
npm run db:seed-users
|
npm run db:seed-users
|
||||||
```
|
```
|
||||||
|
|
||||||
The seed creates administrator `Aewyn` and standard user `Quartermaster`.
|
The seed creates administrator `Aewyn` and standard user `Quartermaster`. It
|
||||||
It generates and displays strong temporary passwords once, then stores only
|
generates and displays strong temporary passwords once, then stores only salted
|
||||||
salted Argon2id hashes.
|
Argon2id hashes.
|
||||||
|
|
||||||
## Develop
|
## 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.
|
when the container starts; keep them out of the image.
|
||||||
|
|
||||||
[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) documents the staging Compose topology
|
[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) documents the staging Compose topology
|
||||||
and Gitea Actions deployment. A push to `main` validates the application,
|
and Gitea Actions deployment. A push to `main` validates the application, builds
|
||||||
builds the candidate image while staging stays online, stops the application
|
the candidate image while staging stays online, stops the application and
|
||||||
and scheduler for the database backup and migration, then starts the release.
|
scheduler for the database backup and migration, then starts the release.
|
||||||
|
|
||||||
## Passwordless authentication
|
## Passwordless authentication
|
||||||
|
|
||||||
@@ -136,7 +135,8 @@ npm run build
|
|||||||
|
|
||||||
Preview the production build with `npm run preview`.
|
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
|
The shared visual language and dashboard conventions are documented in
|
||||||
[docs/DESIGN_SYSTEM.md](docs/DESIGN_SYSTEM.md).
|
[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 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_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 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 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 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 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 (
|
CREATE TABLE IF NOT EXISTS comment_threads (
|
||||||
id TEXT PRIMARY KEY,
|
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,
|
target_id TEXT NOT NULL,
|
||||||
|
pinned_comment_id TEXT REFERENCES comments(id) ON DELETE SET NULL,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
UNIQUE(target_type, target_id)
|
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),
|
author_id TEXT NOT NULL REFERENCES users(id),
|
||||||
content_hash TEXT NOT NULL CHECK(length(content_hash)>0),
|
content_hash TEXT NOT NULL CHECK(length(content_hash)>0),
|
||||||
revision_id TEXT REFERENCES moderated_content_revisions(id),
|
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),
|
policy_version INTEGER NOT NULL CHECK(policy_version>=1),
|
||||||
provider TEXT NOT NULL CHECK(length(trim(provider))>0),
|
provider TEXT NOT NULL CHECK(length(trim(provider))>0),
|
||||||
model TEXT NOT NULL CHECK(length(trim(model))>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,
|
completed_at TEXT,
|
||||||
error_code TEXT,
|
error_code TEXT,
|
||||||
error_message TEXT,
|
error_message TEXT,
|
||||||
UNIQUE(content_type,content_id,content_hash,policy_version,provider,model),
|
|
||||||
CHECK(
|
CHECK(
|
||||||
(content_type IN ('comment','avatar') AND revision_id IS NULL)
|
(content_type IN ('comment','avatar') AND revision_id IS NULL AND article_revision_id IS NULL)
|
||||||
OR (content_type IN ('profile_bio','guide') AND revision_id IS NOT 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(
|
CHECK(
|
||||||
(status='error' AND error_code IS NOT NULL)
|
(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');
|
WHERE status IN ('review','priority_review');
|
||||||
CREATE INDEX IF NOT EXISTS content_moderation_scans_content_idx
|
CREATE INDEX IF NOT EXISTS content_moderation_scans_content_idx
|
||||||
ON content_moderation_scans(content_type,content_id,created_at DESC);
|
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 (
|
CREATE TABLE IF NOT EXISTS content_moderation_reviews (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
scan_id TEXT NOT NULL REFERENCES content_moderation_scans(id),
|
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);
|
ON content_moderation_reviews(scan_id,created_at DESC);
|
||||||
CREATE INDEX IF NOT EXISTS content_moderation_reviews_reviewer_created_idx
|
CREATE INDEX IF NOT EXISTS content_moderation_reviews_reviewer_created_idx
|
||||||
ON content_moderation_reviews(reviewer_id,created_at DESC);
|
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 (
|
CREATE TABLE IF NOT EXISTS user_content_reports (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
content_type TEXT NOT NULL CHECK(content_type IN ('comment','profile_bio','guide','avatar')),
|
content_type TEXT NOT NULL CHECK(content_type IN ('comment','profile_bio','guide','avatar')),
|
||||||
content_id TEXT NOT NULL,
|
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 (
|
category TEXT NOT NULL CHECK(category IN (
|
||||||
'spam','harassment','hate','sexual','violence','self_harm','privacy','other'
|
'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_at TEXT,
|
||||||
reviewed_by TEXT REFERENCES users(id),
|
reviewed_by TEXT REFERENCES users(id),
|
||||||
resolution_reason TEXT,
|
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(
|
CHECK(
|
||||||
(status='pending' AND reviewed_at IS NULL AND reviewed_by IS NULL AND resolution_reason IS NULL)
|
(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
|
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);
|
ON user_content_reports(content_type,content_id,created_at DESC);
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS user_content_reports_one_pending_idx
|
CREATE UNIQUE INDEX IF NOT EXISTS user_content_reports_one_pending_idx
|
||||||
ON user_content_reports(reporter_id,content_type,content_id)
|
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 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 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);
|
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-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-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-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-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-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');
|
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-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-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-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 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 (
|
CREATE TABLE IF NOT EXISTS data_change_proposals (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -499,6 +716,7 @@ CREATE TABLE IF NOT EXISTS data_change_proposals (
|
|||||||
draft_entity_id TEXT,
|
draft_entity_id TEXT,
|
||||||
applied_entity_id TEXT,
|
applied_entity_id TEXT,
|
||||||
summary TEXT NOT NULL,
|
summary TEXT NOT NULL,
|
||||||
|
contributor_note TEXT,
|
||||||
payload_json TEXT NOT NULL,
|
payload_json TEXT NOT NULL,
|
||||||
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected','failed')),
|
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected','failed')),
|
||||||
proposed_by TEXT NOT NULL REFERENCES users(id),
|
proposed_by TEXT NOT NULL REFERENCES users(id),
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ services:
|
|||||||
HOST: 0.0.0.0
|
HOST: 0.0.0.0
|
||||||
PORT: 3000
|
PORT: 3000
|
||||||
BODY_SIZE_LIMIT: 6M
|
BODY_SIZE_LIMIT: 6M
|
||||||
SKIP_DEFAULT_USERS: "true"
|
SKIP_DEFAULT_USERS: 'true'
|
||||||
TDR_DATABASE_PATH: /app/data/shd-build-production.db
|
TDR_DATABASE_PATH: /app/data/shd-build-production.db
|
||||||
TDR_AVATAR_STORAGE_PATH: /app/data/avatars
|
TDR_AVATAR_STORAGE_PATH: /app/data/avatars
|
||||||
TDR_GAME_ICON_STORAGE_PATH: /app/data/game-icons
|
TDR_GAME_ICON_STORAGE_PATH: /app/data/game-icons
|
||||||
@@ -20,8 +20,9 @@ services:
|
|||||||
ORIGIN: https://shd.build
|
ORIGIN: https://shd.build
|
||||||
PASSKEY_RP_ID: shd.build
|
PASSKEY_RP_ID: shd.build
|
||||||
DISCORD_REDIRECT_URI: https://shd.build/auth/discord/callback
|
DISCORD_REDIRECT_URI: https://shd.build/auth/discord/callback
|
||||||
|
UBISOFT_NEWS_IMPORT_ENABLED: 'true'
|
||||||
ports:
|
ports:
|
||||||
- "0.0.0.0:${SHD_PRODUCTION_PORT:-3102}:3000"
|
- '0.0.0.0:${SHD_PRODUCTION_PORT:-3102}:3000'
|
||||||
volumes:
|
volumes:
|
||||||
- shd_production_data:/app/data
|
- shd_production_data:/app/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -11,13 +11,14 @@ services:
|
|||||||
HOST: 0.0.0.0
|
HOST: 0.0.0.0
|
||||||
PORT: 3000
|
PORT: 3000
|
||||||
BODY_SIZE_LIMIT: 6M
|
BODY_SIZE_LIMIT: 6M
|
||||||
SKIP_DEFAULT_USERS: "true"
|
SKIP_DEFAULT_USERS: 'true'
|
||||||
TDR_DATABASE_PATH: /app/data/shd-build-staging.db
|
TDR_DATABASE_PATH: /app/data/shd-build-staging.db
|
||||||
TDR_AVATAR_STORAGE_PATH: /app/data/avatars
|
TDR_AVATAR_STORAGE_PATH: /app/data/avatars
|
||||||
TDR_GAME_ICON_STORAGE_PATH: /app/data/game-icons
|
TDR_GAME_ICON_STORAGE_PATH: /app/data/game-icons
|
||||||
SHD_BUILD_NUMBER: ${SHD_BUILD_NUMBER:-0}
|
SHD_BUILD_NUMBER: ${SHD_BUILD_NUMBER:-0}
|
||||||
|
UBISOFT_NEWS_IMPORT_ENABLED: 'false'
|
||||||
ports:
|
ports:
|
||||||
- "0.0.0.0:${SHD_STAGE_PORT:-5569}:3000"
|
- '0.0.0.0:${SHD_STAGE_PORT:-5569}:3000'
|
||||||
volumes:
|
volumes:
|
||||||
- shd_stage_data:/app/data
|
- shd_stage_data:/app/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
+44
-27
@@ -1,14 +1,20 @@
|
|||||||
# Community content moderation
|
# Community content moderation
|
||||||
|
|
||||||
SHD.build applies one moderation lifecycle to public comments, profile
|
SHD.build applies one moderation lifecycle to public comments, profile
|
||||||
biographies, custom avatars, guides, and build descriptions. The shared server
|
biographies, custom avatars, and Guides. The shared server service lives in
|
||||||
service lives in `src/lib/server/community-moderation.ts`. Content save actions
|
`src/lib/server/community-moderation.ts`. Content save actions must use that
|
||||||
must use that service before publication.
|
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
|
## Publication decisions
|
||||||
|
|
||||||
The moderation service combines local rules with the configured provider
|
The moderation service combines local rules with the configured provider result:
|
||||||
result:
|
|
||||||
|
|
||||||
- Clean content publishes without human review when automatic publication is
|
- Clean content publishes without human review when automatic publication is
|
||||||
enabled.
|
enabled.
|
||||||
@@ -18,8 +24,8 @@ result:
|
|||||||
enabled.
|
enabled.
|
||||||
- An allow term can suppress the matching local term rule. It cannot override a
|
- An allow term can suppress the matching local term rule. It cannot override a
|
||||||
provider flag or threshold.
|
provider flag or threshold.
|
||||||
- A user report sends visible content to Oversight. One report does not hide
|
- A user report sends visible content to Oversight. One report does not hide the
|
||||||
the content because reports can be mistaken or abusive.
|
content because reports can be mistaken or abusive.
|
||||||
|
|
||||||
The service records every automated scan in `content_moderation_scans`.
|
The service records every automated scan in `content_moderation_scans`.
|
||||||
Automated scans do not write to the human ISAC audit log. Human policy changes
|
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
|
- `pending_review`: hidden while Oversight decides
|
||||||
- `removed`: unavailable after author removal or an Oversight decision
|
- `removed`: unavailable after author removal or an Oversight decision
|
||||||
|
|
||||||
Long-form content uses immutable pending revisions. Profile and guide edits that
|
Long-form content uses immutable pending revisions. Profile biographies keep
|
||||||
need review go into `moderated_content_revisions`; the public row keeps its last
|
text candidates in `moderated_content_revisions`. Guides use the complete
|
||||||
approved text. Approval copies the reviewed revision into the public row.
|
structured snapshot in `article_revisions`, including title, deck, document,
|
||||||
Rejection preserves the revision and review evidence without publishing it.
|
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
|
News articles do not use automated content moderation. The `news.manage`
|
||||||
or fail-closed scan error creates an `avatar_reviews` item and is unavailable
|
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
|
through the public avatar route until approval. An Agent report also creates a
|
||||||
review item, but leaves an already-published avatar visible while Oversight
|
review item, but leaves an already-published avatar visible while Oversight
|
||||||
decides.
|
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.
|
evidence. Unsaved changes in other categories remain local drafts.
|
||||||
|
|
||||||
Site Controls requires `site.settings.manage`. Community Oversight handles text
|
Site Controls requires `site.settings.manage`. Community Oversight handles text
|
||||||
decisions through the existing comment and profile permissions. Avatar
|
decisions through the existing comment and profile permissions. Avatar Oversight
|
||||||
Oversight handles custom images through `avatars.review`.
|
handles custom images through `avatars.review`.
|
||||||
|
|
||||||
Moderators must record a reason when they approve, reject, remove, restore,
|
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
|
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
|
## User reports
|
||||||
|
|
||||||
Authenticated Agents can report visible content they do not own. The report
|
Authenticated Agents can report visible comments, biographies, and avatars they
|
||||||
control must identify the content type and stable content ID, validate that the
|
do not own. Registered and anonymous visitors can report visible Guides. A Guide
|
||||||
content remains public, and create a pending `user_content_reports` row.
|
report records the exact published `article_revision_id`; a decision cannot hide
|
||||||
Duplicate pending reports from the same Agent for the same content are rejected.
|
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
|
A reviewer either dismisses the report and keeps the content or resolves the
|
||||||
report through a moderation action. Report details must not copy authentication
|
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
|
## Adding a public UGC field
|
||||||
|
|
||||||
Guide authoring, build descriptions, and later public text fields must complete
|
Guide authoring and later public text fields must complete this checklist:
|
||||||
this checklist:
|
|
||||||
|
|
||||||
1. Normalize and validate the submitted text before scanning.
|
1. Normalize and validate the submitted text before scanning.
|
||||||
2. Call `moderateCommunityText` with a supported content type.
|
2. Call `moderateCommunityText` with a supported content type.
|
||||||
@@ -98,14 +114,15 @@ this checklist:
|
|||||||
4. Publish only an `approved` result.
|
4. Publish only an `approved` result.
|
||||||
5. Store held long-form text as an immutable revision so the last approved
|
5. Store held long-form text as an immutable revision so the last approved
|
||||||
version remains public.
|
version remains public.
|
||||||
6. Call `recordCommunityModerationScan` with the stable content ID, author, hash,
|
6. Call `recordCommunityModerationScan` with the stable content ID, exact
|
||||||
policy version, provider evidence, and local-rule matches.
|
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.
|
7. Add the content type to the Community Oversight queue and permission checks.
|
||||||
8. Add the shared report control to the public rendering.
|
8. Add the shared report control to the public rendering.
|
||||||
9. Delay follower, owner, or reply notifications until the content becomes
|
9. Delay follower, owner, or reply notifications until the content becomes
|
||||||
public.
|
public.
|
||||||
10. Add approval, rejection, report dismissal, author notification, and audit
|
10. Add approval, rejection, report dismissal, author notification, and audit
|
||||||
tests.
|
tests.
|
||||||
11. Confirm every public query selects approved content explicitly.
|
11. Confirm every public query selects approved content explicitly.
|
||||||
|
|
||||||
Do not add a route-specific word filter, provider client, threshold table, or
|
Do not add a route-specific word filter, provider client, threshold table, or
|
||||||
@@ -127,9 +144,9 @@ option, update the legal copy before sending user content under that setting.
|
|||||||
|
|
||||||
Threshold changes increment the policy version. Stored scans retain their model
|
Threshold changes increment the policy version. Stored scans retain their model
|
||||||
and policy version so reviewers can interpret older decisions. Test threshold
|
and policy version so reviewers can interpret older decisions. Test threshold
|
||||||
changes against representative Division terminology before activation,
|
changes against representative Division terminology before activation, including
|
||||||
including weapon names, damage descriptions, kills, status effects, and build
|
weapon names, damage descriptions, kills, status effects, and build strategy
|
||||||
strategy language.
|
language.
|
||||||
|
|
||||||
Database migrations that add moderation states or revision storage follow the
|
Database migrations that add moderation states or revision storage follow the
|
||||||
repository database-preservation procedure: stop this project's development
|
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
|
SHD.build runs in a Node 24 container through the official SvelteKit Node
|
||||||
adapter. The image includes application code, migration and notification
|
adapter. The image includes application code, migration and notification
|
||||||
scripts, and native runtime dependencies. Environment files, databases,
|
scripts, and native runtime dependencies. Environment files, databases, backups,
|
||||||
backups, and user uploads remain outside the image.
|
and user uploads remain outside the image.
|
||||||
|
|
||||||
## Persistent data
|
## Persistent data
|
||||||
|
|
||||||
@@ -16,8 +16,8 @@ mounted at `/app/data`. It contains:
|
|||||||
- uploaded game-data icons under `game-icons/`.
|
- uploaded game-data icons under `game-icons/`.
|
||||||
|
|
||||||
Keep this volume during routine deployments. `docker compose down` without
|
Keep this volume during routine deployments. `docker compose down` without
|
||||||
`--volumes` preserves it. Before a manual backup or restore, stop the `app`
|
`--volumes` preserves it. Before a manual backup or restore, stop the `app` and
|
||||||
and `scheduler` services because both access the embedded database.
|
`scheduler` services because both access the embedded database.
|
||||||
|
|
||||||
## Staging environment
|
## Staging environment
|
||||||
|
|
||||||
@@ -29,6 +29,21 @@ Staging defaults to:
|
|||||||
- Passkey relying-party ID: `shd.build`
|
- Passkey relying-party ID: `shd.build`
|
||||||
- Adapter canonical origin: `https://stage.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:
|
Edit its encrypted configuration locally:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -38,12 +53,11 @@ npm run env:encrypt:staging
|
|||||||
```
|
```
|
||||||
|
|
||||||
Add `https://stage.shd.build/auth/discord/callback` to the Discord application's
|
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
|
allowed OAuth redirect URIs. Point the staging DNS record at the deployment host
|
||||||
host and configure the host's TLS reverse proxy to forward
|
and configure the host's TLS reverse proxy to forward `stage.shd.build` to port
|
||||||
`stage.shd.build` to port `5569` on the staging host. Docker publishes this
|
`5569` on the staging host. Docker publishes this port on the host interfaces so
|
||||||
port on the host interfaces so a containerized reverse proxy or tunnel can
|
a containerized reverse proxy or tunnel can reach it; firewall the port to
|
||||||
reach it; firewall the port to trusted LAN/tunnel sources when the host is
|
trusted LAN/tunnel sources when the host is reachable from other networks.
|
||||||
reachable from other networks.
|
|
||||||
|
|
||||||
The trusted staging runner decrypts `.env.staging` into a mode-`0600` runtime
|
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
|
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 runner
|
||||||
|
|
||||||
Gitea Actions requires an `act_runner`. Register a trusted runner on the
|
Gitea Actions requires an `act_runner`. Register a trusted runner on the staging
|
||||||
staging host from the repository's **Settings → Actions → Runners** page. The
|
host from the repository's **Settings → Actions → Runners** page. The host needs
|
||||||
host needs Node/npm, Docker Engine, and Docker Compose v2. The runner account
|
Node/npm, Docker Engine, and Docker Compose v2. The runner account must be able
|
||||||
must be able to use Docker and write to the deployment directory.
|
to use Docker and write to the deployment directory.
|
||||||
|
|
||||||
Use runner labels that provide an isolated validation job and a host deployment
|
Use runner labels that provide an isolated validation job and a host deployment
|
||||||
job:
|
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
|
`.gitea/workflows/deploy-staging.yml` runs for pushes to `main` and manual
|
||||||
dispatches. The trusted staging runner builds each commit-tagged image locally,
|
dispatches. The trusted staging runner builds each commit-tagged image locally,
|
||||||
so the workflow needs no registry credentials. Gitea assigns
|
so the workflow needs no registry credentials. Gitea assigns `gitea.run_number`
|
||||||
`gitea.run_number` to `SHD_BUILD_NUMBER`; each new workflow run receives the
|
to `SHD_BUILD_NUMBER`; each new workflow run receives the next integer, while
|
||||||
next integer, while retries of the same run retain their number.
|
retries of the same run retain their number.
|
||||||
|
|
||||||
## Deployment lifecycle
|
## Deployment lifecycle
|
||||||
|
|
||||||
@@ -114,12 +128,28 @@ The workflow:
|
|||||||
The outage covers backup, migration, and container startup. Migrations may also
|
The outage covers backup, migration, and container startup. Migrations may also
|
||||||
remove records that use retired data formats when a release explicitly ends
|
remove records that use retired data formats when a release explicitly ends
|
||||||
compatibility. The deployment backup remains the recovery source for those
|
compatibility. The deployment backup remains the recovery source for those
|
||||||
records; the application does not repair or reinterpret them at runtime.
|
records; the application does not repair or reinterpret them at runtime. The
|
||||||
The scheduler calls a bearer-authenticated endpoint on the application service
|
scheduler calls a bearer-authenticated endpoint on the application service once
|
||||||
once per minute. Scheduled message delivery therefore uses the web process's
|
per minute. Scheduled message delivery therefore uses the web process's existing
|
||||||
existing embedded-database connection instead of opening the locked database
|
embedded-database connection instead of opening the locked database from a
|
||||||
from a second process. The endpoint runs automated system messages, Oversight
|
second process. The endpoint runs automated system messages, Oversight
|
||||||
consolidation, and email digest checks and is not published through a host port.
|
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
|
## Prelaunch operation
|
||||||
|
|
||||||
@@ -131,8 +161,8 @@ for a database that does not contain the row:
|
|||||||
- News, Database, Builds, Guides, and Builder: enabled
|
- News, Database, Builds, Guides, and Builder: enabled
|
||||||
|
|
||||||
The migration uses an insert-if-missing seed. Later deployments preserve the
|
The migration uses an insert-if-missing seed. Later deployments preserve the
|
||||||
operator's settings, and configuration changes take effect without a restart.
|
operator's settings, and configuration changes take effect without a restart. A
|
||||||
A full environment bootstrap copies the source row. Canonical ISAC game-data
|
full environment bootstrap copies the source row. Canonical ISAC game-data
|
||||||
imports leave the target environment's launch settings unchanged.
|
imports leave the target environment's launch settings unchanged.
|
||||||
|
|
||||||
Use this operator flow after the first deployment:
|
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
|
session before public release. Sign in and verify the same disabled routes
|
||||||
remain unavailable.
|
remain unavailable.
|
||||||
|
|
||||||
Keep Coming Soon enabled while preparing production. Section controls can be
|
Keep Coming Soon enabled while preparing production. Section controls can be set
|
||||||
set in advance because the launch gate takes precedence for signed-out traffic.
|
in advance because the launch gate takes precedence for signed-out traffic. Set
|
||||||
Set Account Creation to Disabled if prelaunch access must remain limited to
|
Account Creation to Disabled if prelaunch access must remain limited to existing
|
||||||
existing identities. Switch the Launch Gate to Public Launch after verification.
|
identities. Switch the Launch Gate to Public Launch after verification. Disabled
|
||||||
Disabled sections remain absent from navigation and unavailable by direct URL
|
sections remain absent from navigation and unavailable by direct URL for every
|
||||||
for every user, including authenticated agents. Authentication bypasses only
|
user, including authenticated agents. Authentication bypasses only the Coming
|
||||||
the Coming Soon gate.
|
Soon gate.
|
||||||
|
|
||||||
Turn Coming Soon on to restore the promotional gate. Site-control changes append
|
Turn Coming Soon on to restore the promotional gate. Site-control changes append
|
||||||
`system_configuration / site_access.updated` audit events, including the
|
`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
|
npm run stage:bootstrap:export
|
||||||
```
|
```
|
||||||
|
|
||||||
The command writes a timestamped, ignored JSON bundle under `data/backups/`.
|
The command writes a timestamped, ignored JSON bundle under `data/backups/`. The
|
||||||
The bundle includes user records and password hashes. Transfer it over an
|
bundle includes user records and password hashes. Transfer it over an encrypted
|
||||||
encrypted channel, keep it out of Git, then copy it to the staging host and run:
|
channel, keep it out of Git, then copy it to the staging host and run:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
/opt/shd-build-stage/bootstrap-staging.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
|
The bootstrap flow stops the app and scheduler, creates a safety backup of the
|
||||||
target, verifies that staging is unused, imports the entire development
|
target, verifies that staging is unused, imports the entire development database
|
||||||
database and uploads, and restarts both services. Delete the transfer copy from
|
and uploads, and restarts both services. Delete the transfer copy from the
|
||||||
the staging host after verifying the imported record counts and critical
|
staging host after verifying the imported record counts and critical entities.
|
||||||
entities. The copy under the local ignored backup directory remains the
|
The copy under the local ignored backup directory remains the recovery source.
|
||||||
recovery source.
|
|
||||||
|
|
||||||
Because this is a full initial clone, development session tokens and account
|
Because this is a full initial clone, development session tokens and account
|
||||||
data also enter staging. Staging and development remain independent from that
|
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
|
/secure/path/shd-production-bootstrap-YYYY-MM-DDTHH-MM-SSZ.json
|
||||||
```
|
```
|
||||||
|
|
||||||
The importer verifies the bundle target, creates a production safety backup,
|
The importer verifies the bundle target, creates a production safety backup, and
|
||||||
and refuses to replace a production database containing users, builds, audit
|
refuses to replace a production database containing users, builds, audit events,
|
||||||
events, or proposals. Remove the remote transfer copy after verification.
|
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
|
### Pull production ISAC data into development
|
||||||
|
|
||||||
@@ -312,10 +363,10 @@ notifications, settings, and audit history untouched.
|
|||||||
|
|
||||||
### Pull production ISAC data into staging
|
### Pull production ISAC data into staging
|
||||||
|
|
||||||
Manually run **Replace staging ISAC data from production** in Gitea Actions.
|
Manually run **Replace staging ISAC data from production** in Gitea Actions. The
|
||||||
The workflow exports a consistent production snapshot, restarts production,
|
workflow exports a consistent production snapshot, restarts production, then
|
||||||
then stops staging and imports it. The staging importer creates full and
|
stops staging and imports it. The staging importer creates full and ISAC-only
|
||||||
ISAC-only safety backups before replacement. Deployments do not run this flow.
|
safety backups before replacement. Deployments do not run this flow.
|
||||||
|
|
||||||
### Promote managed images without replacing other data
|
### 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
|
interval and defaults to six hours. Low-capacity alerts require both remaining
|
||||||
pools to cross their thresholds: `REMOVE_BG_LOW_CREDIT_THRESHOLD` defaults to
|
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
|
five paid credits, and `REMOVE_BG_LOW_FREE_CALL_THRESHOLD` defaults to ten free
|
||||||
calls. Alerts are mandatory ISAC notifications for active Administrators and
|
calls. Alerts are mandatory ISAC notifications for active Administrators and are
|
||||||
are deduplicated for each warning or critical episode.
|
deduplicated for each warning or critical episode.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm run images:export
|
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
|
After reviewing an intentional metadata or file replacement, add
|
||||||
`--update-existing`. Imported changes append `image.imported` or
|
`--update-existing`. Imported changes append `image.imported` or
|
||||||
`image.import_updated` audit events. Remove the transferred bundle after
|
`image.import_updated` audit events. Remove the transferred bundle after
|
||||||
verifying both environments because it may contain unpublished visual assets
|
verifying both environments because it may contain unpublished visual assets and
|
||||||
and creator identifiers.
|
creator identifiers.
|
||||||
|
|
||||||
The `.env.staging` and `.env.production` npm import commands are intended for
|
The `.env.staging` and `.env.production` npm import commands are intended for
|
||||||
maintenance performed directly in an environment where those database and
|
maintenance performed directly in an environment where those database and
|
||||||
|
|||||||
+942
-858
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
|
## Application stack
|
||||||
|
|
||||||
- SvelteKit 2 / Svelte 5 with TypeScript
|
- SvelteKit 2 / Svelte 5 with TypeScript
|
||||||
- Embedded Turso Database via `@tursodatabase/database` for durable application data
|
- Embedded Turso Database via `@tursodatabase/database` for durable application
|
||||||
- Auth.js-compatible server authentication with Discord OAuth and credentials providers
|
data
|
||||||
- Argon2id password hashing, server-side sessions, CSRF protection, and role checks
|
- 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
|
- Browser `localStorage` only for anonymous build drafts
|
||||||
|
|
||||||
## Product boundaries
|
## Product boundaries
|
||||||
|
|
||||||
- Public: browse, sort, and view published builds and guides.
|
- Public: browse, sort, and view published builds and guides.
|
||||||
- Anonymous: create and edit local drafts.
|
- 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.
|
- Editor: manage builder source data.
|
||||||
- Admin: manage users, roles, source data, and audit history.
|
- Admin: manage users, roles, source data, and audit history.
|
||||||
|
|
||||||
Server code checks authorization for each mutation. UI visibility has no effect
|
Server code checks authorization for each mutation. UI visibility has no effect
|
||||||
on server authorization.
|
on server authorization.
|
||||||
|
|
||||||
Weapon and gear range calculations must preserve decimal endpoints
|
Weapon and gear range calculations must preserve decimal endpoints and follow
|
||||||
and follow the tested progression rules in
|
the tested progression rules in
|
||||||
[`STAT_LEVEL_SCALING.md`](STAT_LEVEL_SCALING.md). Leveling is linear rather than
|
[`STAT_LEVEL_SCALING.md`](STAT_LEVEL_SCALING.md). Leveling is linear rather than
|
||||||
compounded. The presentation layer truncates game-matching integer values.
|
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
|
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.
|
for five seconds and clears the process cache after an operator saves changes.
|
||||||
|
|
||||||
Agents with `site.settings.manage` edit the row from ISAC → System
|
Agents with `site.settings.manage` edit the row from ISAC → System Configuration
|
||||||
Configuration → Site Controls. The save action requires an explicit value for
|
→ Site Controls. The save action requires an explicit value for all seven
|
||||||
all seven controls and a confirmation token. A successful update records
|
controls and a confirmation token. A successful update records
|
||||||
`system_configuration / site_access.updated` with the operator, target row, and
|
`system_configuration / site_access.updated` with the operator, target row, and
|
||||||
complete before/after snapshots.
|
complete before/after snapshots.
|
||||||
|
|
||||||
@@ -84,22 +88,102 @@ closed.
|
|||||||
|
|
||||||
## Community content moderation
|
## Community content moderation
|
||||||
|
|
||||||
Comments, profile biographies, custom avatars, guides, and build descriptions
|
Comments, profile biographies, custom avatars, and Guides share the moderation
|
||||||
share the moderation lifecycle documented in
|
lifecycle documented in [`CONTENT_MODERATION.md`](CONTENT_MODERATION.md). Local
|
||||||
[`CONTENT_MODERATION.md`](CONTENT_MODERATION.md). Local rules and the configured
|
rules and the configured provider classify each submission before publication.
|
||||||
provider classify each submission before publication. Clean submissions can
|
Clean submissions can publish without human work. Flagged submissions and
|
||||||
publish without human work. Flagged submissions and fail-closed provider errors
|
fail-closed provider errors enter the appropriate Oversight queue.
|
||||||
enter the appropriate Oversight queue.
|
|
||||||
|
|
||||||
Agents may report visible content they do not own. Reports enter Oversight
|
Agents may report visible content they do not own. Reports enter Oversight
|
||||||
without hiding the content. Human decisions require a reason and append an
|
without hiding the content. Human decisions require a reason and append an audit
|
||||||
audit event. Long-form edits that need review use immutable pending revisions
|
event. Long-form edits that need review use immutable pending revisions so the
|
||||||
so the last approved biography, guide, or description remains public.
|
last approved biography or Guide remains public.
|
||||||
|
|
||||||
ISAC Site Controls owns provider behavior, content scopes, thresholds, link and
|
ISAC Site Controls owns provider behavior, content scopes, thresholds, link and
|
||||||
PII checks, and allowed or banned terms. API credentials remain in server
|
PII checks, and allowed or banned terms. API credentials remain in server
|
||||||
environment configuration.
|
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
|
## Increment plan
|
||||||
|
|
||||||
1. Product shell and anonymous local-draft builder.
|
1. Product shell and anonymous local-draft builder.
|
||||||
@@ -109,10 +193,11 @@ environment configuration.
|
|||||||
5. Publishing, discovery, ratings, comments, favorites, and copy flow.
|
5. Publishing, discovery, ratings, comments, favorites, and copy flow.
|
||||||
6. Rich build guides and moderation.
|
6. Rich build guides and moderation.
|
||||||
7. Admin editors, validation, audit log, and data import/export.
|
7. Admin editors, validation, audit log, and data import/export.
|
||||||
|
|
||||||
# Administrator bootstrap
|
# Administrator bootstrap
|
||||||
|
|
||||||
The authentication service promotes the first authenticated user to `admin` if the
|
The authentication service promotes the first authenticated user to `admin` if
|
||||||
database does not contain an administrator. Promotion uses one conditional
|
the database does not contain an administrator. Promotion uses one conditional
|
||||||
database update so concurrent first sign-ins cannot both become administrators.
|
database update so concurrent first sign-ins cannot both become administrators.
|
||||||
Once an administrator exists, later sign-ins never alter roles.
|
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.
|
The scripts write versioned JSON documents beneath `data/backups/` by default.
|
||||||
Git ignores that directory because backups contain private user data,
|
Git ignores that directory because backups contain private user data, including
|
||||||
including password hashes.
|
password hashes.
|
||||||
|
|
||||||
To restore, first create an empty database and apply the schema. Then run:
|
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.
|
The restore operation refuses to import into any database containing records.
|
||||||
This guard prevents accidental merging or overwriting of contributor data.
|
This guard prevents accidental merging or overwriting of contributor data. Stop
|
||||||
Stop the application before backup or restore so the embedded database file is
|
the application before backup or restore so the embedded database file is not
|
||||||
not locked by another process.
|
locked by another process.
|
||||||
|
|
||||||
## Development database maintenance
|
## Development database maintenance
|
||||||
|
|
||||||
Treat the development database as persistent data. Stop this project's
|
Treat the development database as persistent data. Stop this project's
|
||||||
development server before backup, export, restore, migration, or other work
|
development server before backup, export, restore, migration, or other work
|
||||||
requiring exclusive access. Create and verify a JSON backup, perform the
|
requiring exclusive access. Create and verify a JSON backup, perform the
|
||||||
prepared operation, verify schema state and critical record counts, then
|
prepared operation, verify schema state and critical record counts, then restart
|
||||||
restart and health-check the application. Prefer additive, repeatable
|
and health-check the application. Prefer additive, repeatable migrations and
|
||||||
migrations and retain the verified backup if maintenance fails.
|
retain the verified backup if maintenance fails.
|
||||||
|
|
||||||
Production and staging remain shared persistent environments and retain the
|
Production and staging remain shared persistent environments and retain the
|
||||||
deployment, backup, synchronization, and maintenance safeguards documented in
|
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
|
Every successful ISAC mutation appends an event to `audit_log` through the
|
||||||
shared server audit utility. Events identify the operator, operational category,
|
shared server audit utility. Events identify the operator, operational category,
|
||||||
specific action, target entity, timestamp, and useful before/after evidence.
|
specific action, target entity, timestamp, and useful before/after evidence. The
|
||||||
The audit registry is read-only and requires `audit.view`; the seeded Auditor
|
audit registry is read-only and requires `audit.view`; the seeded Auditor role
|
||||||
role grants that permission without granting mutation capabilities.
|
grants that permission without granting mutation capabilities.
|
||||||
|
|
||||||
Audit history is immutable. The server implements “Revert” as a new compensating
|
Audit history is immutable. The server implements “Revert” as a new compensating
|
||||||
mutation rather than deletion or modification of the original event:
|
mutation rather than deletion or modification of the original event:
|
||||||
|
|
||||||
1. The operator must hold `audit.revert` in addition to any other access.
|
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
|
2. The reversion registry must support the original action and the event must
|
||||||
before-snapshot.
|
contain a usable before-snapshot.
|
||||||
3. The UI requires a second, explicit confirmation.
|
3. The UI requires a second, explicit confirmation.
|
||||||
4. The server reloads the target and compares it with the original
|
4. The server reloads the target and compares it with the original
|
||||||
after-snapshot, ignoring bookkeeping timestamps. A mismatch indicates newer
|
after-snapshot, ignoring bookkeeping timestamps. A mismatch indicates newer
|
||||||
work and blocks automatic compensation.
|
work and blocks automatic compensation.
|
||||||
5. The server revalidates referenced roles, permissions, and relationships before
|
5. The server revalidates referenced roles, permissions, and relationships
|
||||||
restoration. Restoring administrator authority also requires full
|
before restoration. Restoring administrator authority also requires full
|
||||||
administrative access.
|
administrative access.
|
||||||
6. The server restores the previous state and appends an `audit.reverted` event
|
6. The server restores the previous state and appends an `audit.reverted` event
|
||||||
whose target is the original audit-event ID.
|
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
|
# Moderated game-data contributions
|
||||||
|
|
||||||
ISAC data access uses `data.view`, `data.propose`, `data.manage`, and
|
ISAC data access uses `data.view`, `data.propose`, `data.manage`, and
|
||||||
`data.approve`. Data Viewers can inspect registries. Data Contributors can submit
|
`data.approve`. Data Viewers can inspect registries. Data Contributors can
|
||||||
the same editor forms, but the server serializes each request into
|
submit the same editor forms, but the server serializes each request into
|
||||||
`data_change_proposals` instead of mutating canonical tables. Data Oversight
|
`data_change_proposals` instead of mutating canonical tables. Data Oversight
|
||||||
reviews the queue and must confirm approval or rejection.
|
reviews the queue and must confirm approval or rejection.
|
||||||
|
|
||||||
Approval replays the stored form through its original SvelteKit server action.
|
Approval replays the stored form through its original SvelteKit server action.
|
||||||
This preserves canonical validation, relational updates, icon processing, and the
|
This preserves canonical validation, relational updates, icon processing, and
|
||||||
normal game-data audit event. Proposal creation and review decisions also produce
|
the normal game-data audit event. Proposal creation and review decisions also
|
||||||
separate `data_moderation` audit events. Failed canonical validation marks a
|
produce separate `data_moderation` audit events. Failed canonical validation
|
||||||
proposal as failed without changing live data. Pending counts appear alongside
|
marks a proposal as failed without changing live data. Pending counts appear
|
||||||
each data registry in ISAC so collaborators can see concurrent suggestions.
|
alongside each data registry in ISAC so collaborators can see concurrent
|
||||||
|
suggestions.
|
||||||
|
|
||||||
The protected seeded roles are Data Viewer, Data Contributor, Data Editor, and
|
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. Data Editor retains direct-write access for trusted operators;
|
||||||
Data Oversight combines direct-write access with queue review. New ISAC data
|
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
|
modules must use the shared moderation interception path and audit both the
|
||||||
mutation and proposal lifecycle.
|
final mutation and proposal lifecycle.
|
||||||
|
|
||||||
Mechanic programs remain part of their owning source record. Talent, OS
|
Mechanic programs remain part of their owning source record. Talent, OS
|
||||||
Protocol, Skill Chip, and Specialization proposals carry the complete typed
|
Protocol, Skill Chip, and Specialization proposals carry the complete typed
|
||||||
program with the rest of the source payload. Oversight reviews and approves
|
program with the rest of the source payload. Oversight reviews and approves that
|
||||||
that program through the source module's canonical validation path. There is
|
program through the source module's canonical validation path. There is no
|
||||||
no standalone Trigger proposal module or pending Trigger dependency.
|
standalone Trigger proposal module or pending Trigger dependency.
|
||||||
|
|
||||||
Each authenticated, active registered agent receives `isac.access`,
|
Each authenticated, active registered agent receives `isac.access`, `data.view`,
|
||||||
`data.view`, and `data.propose` as baseline permissions in the authorization
|
and `data.propose` as baseline permissions in the authorization layer. The
|
||||||
layer. The authorization layer grants this baseline without a removable role
|
authorization layer grants this baseline without a removable role assignment, so
|
||||||
assignment, so existing users and each account-creation method receive the same
|
existing users and each account-creation method receive the same access.
|
||||||
access. Registered agents can inspect live source data and submit
|
Registered agents can inspect live source data and submit moderated proposals,
|
||||||
moderated proposals, but cannot mutate canonical records, review the
|
but cannot mutate canonical records, review the queue, or access unrelated ISAC
|
||||||
queue, or access unrelated ISAC operations without additional permissions.
|
operations without additional permissions. Unsupported destructive identity
|
||||||
Unsupported destructive identity operations, account deletion, incomplete
|
operations, account deletion, incomplete snapshots, stale targets, and events
|
||||||
snapshots, stale targets, and events whose dependencies no longer exist are
|
whose dependencies no longer exist are marked non-reversible or rejected.
|
||||||
marked non-reversible or rejected. Recovery for those cases requires a
|
Recovery for those cases requires a specialized workflow rather than inferred
|
||||||
specialized workflow rather than inferred database writes.
|
database writes.
|
||||||
|
|
||||||
# System metrics
|
# System metrics
|
||||||
|
|
||||||
ISAC uses `/isac` as its canonical route namespace. The server
|
ISAC uses `/isac` as its canonical route namespace. The server redirects legacy
|
||||||
redirects legacy `/admin` URLs to their matching `/isac` location so old
|
`/admin` URLs to their matching `/isac` location so old notifications and
|
||||||
notifications and external bookmarks remain usable.
|
external bookmarks remain usable.
|
||||||
|
|
||||||
The read-only `/isac/metrics` route requires `metrics.view` and collects a
|
The read-only `/isac/metrics` route requires `metrics.view` and collects a
|
||||||
point-in-time process-local health snapshot. Node and operating-system APIs
|
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
|
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
|
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
|
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
|
long gaps provide direction rather than a monitoring-grade time series.
|
||||||
and historical analysis should use a dedicated external telemetry
|
Production alerting and historical analysis should use a dedicated external
|
||||||
system. This surface omits secrets, environment-variable values,
|
telemetry system. This surface omits secrets, environment-variable values, and
|
||||||
and full database paths.
|
full database paths.
|
||||||
|
|
||||||
The same surface reports remove.bg paid credits and free API calls as separate
|
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
|
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
|
provider's `X-Credits-Charged` value to the persisted estimate. Warning and
|
||||||
critical transitions create one alert episode with a unique delivery for each
|
critical transitions create one alert episode with a unique delivery for each
|
||||||
active Administrator. A later critical transition may alert again; repeated
|
active Administrator. A later critical transition may alert again; repeated
|
||||||
scheduler runs within the same episode do not. Provider errors preserve the
|
scheduler runs within the same episode do not. Provider errors preserve the last
|
||||||
last known balance and never expose the API key or raw response body.
|
known balance and never expose the API key or raw response body.
|
||||||
|
|
||||||
# Weapon category bonuses
|
# 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
|
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
|
retain an underlying category and inherit the same profile as a standard weapon
|
||||||
of that type. The combat summary preserves the existing equipped-weapon
|
of that type. The combat summary preserves the existing equipped-weapon
|
||||||
semantics: each equipped weapon contributes its inherited profile to the
|
semantics: each equipped weapon contributes its inherited profile to the Agent's
|
||||||
Agent's accumulated Attributes.
|
accumulated Attributes.
|
||||||
|
|
||||||
ISAC exposes one category-profile editor above the weapon registry. Operators
|
ISAC exposes one category-profile editor above the weapon registry. Operators
|
||||||
may change the three positive values, while the Attribute identities and order
|
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
|
`weapons.exotic_talent_id`. The weapon editor lists registered exotic talents
|
||||||
and identifies talents assigned to another weapon. Server validation rejects
|
and identifies talents assigned to another weapon. Server validation rejects
|
||||||
standard talents, inactive talents, and talents already assigned elsewhere.
|
standard talents, inactive talents, and talents already assigned elsewhere.
|
||||||
Changing or clearing a weapon relationship leaves the previous talent active
|
Changing or clearing a weapon relationship leaves the previous talent active and
|
||||||
and available for another assignment.
|
available for another assignment.
|
||||||
|
|
||||||
Tier I and its modeled mechanic are required when a contributor saves an
|
Tier I and its modeled mechanic are required when a contributor saves an exotic
|
||||||
exotic talent. Tier II may remain undocumented. An undocumented Tier II has no
|
talent. Tier II may remain undocumented. An undocumented Tier II has no
|
||||||
`talent_tiers` row or conditional-effect value variant. A later Talent Registry
|
`talent_tiers` row or conditional-effect value variant. A later Talent Registry
|
||||||
edit adds the Tier II projection and mechanic values without changing the
|
edit adds the Tier II projection and mechanic values without changing the weapon
|
||||||
weapon reference.
|
reference.
|
||||||
|
|
||||||
# OS Protocol progression
|
# 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;
|
`os_protocol_secondary_effects`. Every matching protocol inherits that profile;
|
||||||
secondary values are not copied onto individual protocol records.
|
secondary values are not copied onto individual protocol records.
|
||||||
|
|
||||||
The unique primary value uses `base × (1 + (level − 1) / 49)` for levels 1–50, while
|
The unique primary value uses `base × (1 + (level − 1) / 49)` for levels 1–50,
|
||||||
talent effects use `base × (1 + upgrades / 5)` for zero to five upgrades.
|
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
|
Server actions validate all relationships and append the full owned graph to the
|
||||||
the existing `game_data` audit history.
|
existing `game_data` audit history.
|
||||||
|
|
||||||
### Weapon Mods and tiered weapon slots
|
### Weapon Mods and tiered weapon slots
|
||||||
|
|
||||||
`weapon_mod_slots` records each weapon's available slot vocabulary per tier.
|
`weapon_mod_slots` records each weapon's available slot vocabulary per tier. The
|
||||||
The editor captures one shared set for T1/T2 and the single shared slot omitted
|
editor captures one shared set for T1/T2 and the single shared slot omitted at
|
||||||
at T0, then expands that input into the normalized per-tier rows. Operators may
|
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
|
save partial rows when the in-game configuration is unavailable. ISAC derives
|
||||||
`CONFIG REQUIRED` until every available tier contains four unique slots at T0
|
`CONFIG REQUIRED` until every available tier contains four unique slots at T0 or
|
||||||
or five at T1/T2. Regular Weapon Mods declare a single slot type and one or more
|
five at T1/T2. Regular Weapon Mods declare a single slot type and one or more
|
||||||
compatible base weapon categories through
|
compatible base weapon categories through `weapon_mod_compatible_categories`;
|
||||||
`weapon_mod_compatible_categories`; eligibility requires both a category match
|
eligibility requires both a category match and the slot to exist on the equipped
|
||||||
and the slot to exist on the equipped weapon at its selected tier.
|
weapon at its selected tier.
|
||||||
|
|
||||||
Exotic Weapon Mods instead reference one `exotic_weapon_id`. They do not carry
|
Exotic Weapon Mods instead reference one `exotic_weapon_id`. They do not carry
|
||||||
regular category compatibility. The builder treats them as permanent equipment
|
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
|
All regular Weapon Mods permit Firepower, Toughness, or Engineering as a
|
||||||
builder-time primary choice. `weapon_mod_quality_values` stores the shared
|
builder-time primary choice. `weapon_mod_quality_values` stores the shared
|
||||||
level-1 and level-20 primary roll endpoints for each quality; primary values
|
level-1 and level-20 primary roll endpoints for each quality; primary values use
|
||||||
use linear interpolation from levels 1–20.
|
linear interpolation from levels 1–20.
|
||||||
|
|
||||||
`weapon_mod_secondary_options` is the shared pool from which a build selects
|
`weapon_mod_secondary_options` is the shared pool from which a build selects two
|
||||||
two distinct secondary attributes. Every option owns a minimum and maximum at
|
distinct secondary attributes. Every option owns a minimum and maximum at each
|
||||||
each quality through `weapon_mod_secondary_quality_values`; secondary
|
quality through `weapon_mod_secondary_quality_values`; secondary availability is
|
||||||
availability is not tied to a numbered slot in canonical data. Mod Effects are
|
not tied to a numbered slot in canonical data. Mod Effects are triggerless, hold
|
||||||
triggerless, hold one or two attribute references, and store fixed per-quality
|
one or two attribute references, and store fixed per-quality values in
|
||||||
values in `weapon_mod_effect_quality_values`.
|
`weapon_mod_effect_quality_values`.
|
||||||
|
|
||||||
### Gear Mods and tier slot capacity
|
### Gear Mods and tier slot capacity
|
||||||
|
|
||||||
Gear Mod definitions are standard-quality records only; there is no exotic
|
Gear Mod definitions are standard-quality records only; there is no exotic Gear
|
||||||
Gear Mod branch. Every definition supplies primary roll ranges for Standard,
|
Mod branch. Every definition supplies primary roll ranges for Standard,
|
||||||
Specialized, Superior, and High-end quality at Levels 1 and 20. Values between
|
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
|
those endpoints use linear interpolation. A build instance selects one primary
|
||||||
from Firepower, Toughness, Engineering, Health, or Armor.
|
attribute from Firepower, Toughness, Engineering, Health, or Armor.
|
||||||
|
|
||||||
The two secondary slots draw distinct attributes from one shared option pool.
|
The two secondary slots draw distinct attributes from one shared option pool.
|
||||||
Each available secondary attribute stores its own minimum and maximum for all
|
Each available secondary attribute stores its own minimum and maximum for all
|
||||||
four qualities. Gear tier determines capacity without per-item configuration:
|
four qualities. Gear tier determines capacity without per-item configuration: T0
|
||||||
T0 gear has one Gear Mod slot; T1 and T2 gear have two.
|
gear has one Gear Mod slot; T1 and T2 gear have two.
|
||||||
|
|
||||||
The `gear_mods`, `gear_mod_quality_values`, `gear_mod_secondary_options`, and
|
The `gear_mods`, `gear_mod_quality_values`, `gear_mod_secondary_options`, and
|
||||||
`gear_mod_secondary_quality_values` tables store canonical data.
|
`gear_mod_secondary_quality_values` tables store canonical data. Successful
|
||||||
Successful mutations use `gear_mod.created`, `gear_mod.updated`,
|
mutations use `gear_mod.created`, `gear_mod.updated`, `gear_mod.archived`, and
|
||||||
`gear_mod.archived`, and `gear_mod.unarchived` audit actions. Contributor
|
`gear_mod.unarchived` audit actions. Contributor submissions use the `gearmods`
|
||||||
submissions use the `gearmods` proposal module and remain non-canonical until
|
proposal module and remain non-canonical until approved by Oversight.
|
||||||
approved by Oversight.
|
|
||||||
|
|
||||||
# Skill Chip quality scaling
|
# Skill Chip quality scaling
|
||||||
|
|
||||||
Skill Chip sets support Superior and High-end chip instances. Each set owns one
|
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
|
2-piece bonus, distinct primary and secondary stat-option pools, and one 3-piece
|
||||||
3-piece Talent with a typed conditional rule. Primary and secondary options
|
Talent with a typed conditional rule. Primary and secondary options reference
|
||||||
reference active Attributes that define Skill Chip minimum and maximum roll
|
active Attributes that define Skill Chip minimum and maximum roll values. A
|
||||||
values. A configured chip rolls one primary and two distinct secondary stats
|
configured chip rolls one primary and two distinct secondary stats from those
|
||||||
from those pools.
|
pools.
|
||||||
|
|
||||||
Each active Skill Chip definition belongs to one active specialization ability
|
Each active Skill Chip definition belongs to one active specialization ability
|
||||||
through `skill_chip_sets.specialization_ability_id`. An ability has capacity for
|
through `skill_chip_sets.specialization_ability_id`. An ability has capacity for
|
||||||
three active definitions. The save and restore actions enforce that capacity,
|
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
|
and the schema requires the foreign key for each row. A specialization focus is
|
||||||
builder-ready only when each of its three
|
builder-ready only when each of its three assigned abilities has all three
|
||||||
assigned abilities has all three active Skill Chip definitions. Skill Chip
|
active Skill Chip definitions. Skill Chip mutations refresh readiness for
|
||||||
mutations refresh readiness for focuses that reference both the previous and
|
focuses that reference both the previous and resulting ability.
|
||||||
resulting ability.
|
|
||||||
|
|
||||||
Both set-bonus progression systems use the number of equipped High-end chips as
|
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
|
the interpolation step. For `h` High-end chips, where `h` is zero through three,
|
||||||
three, a configured minimum and maximum resolve as
|
a configured minimum and maximum resolve as
|
||||||
`minimum + (maximum − minimum) × h / 3`. Zero High-end chips therefore produce
|
`minimum + (maximum − minimum) × h / 3`. Zero High-end chips therefore produce
|
||||||
the minimum, while three produce the maximum. The editor stores endpoints and
|
the minimum, while three produce the maximum. The editor stores endpoints and
|
||||||
previews all four states, preventing duplicated tier data from diverging.
|
previews all four states, preventing duplicated tier data from diverging. The
|
||||||
The `game_data` audit stream captures Skill Chip mutations, owned relationships,
|
`game_data` audit stream captures Skill Chip mutations, owned relationships, and
|
||||||
and safe icon metadata.
|
safe icon metadata.
|
||||||
|
|
||||||
# Mechanic programs and loadout state
|
# 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
|
- 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.
|
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
|
Builder calculations use Tier I while an equipped exotic Talent has no Tier II
|
||||||
II mechanic variant.
|
mechanic variant.
|
||||||
- OS Protocol Talents store a base value. An outcome may use an expression that
|
- OS Protocol Talents store a base value. An outcome may use an expression that
|
||||||
reads `equipment.protocolTalentUpgrades`, from zero through five, so selected
|
reads `equipment.protocolTalentUpgrades`, from zero through five, so selected
|
||||||
values can scale to twice the base at upgrade five.
|
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
|
The optional Action Family catalog provides authoring recipes for common
|
||||||
mechanics. Applying a recipe copies its typed scenario inputs, state pools, and
|
mechanics. Applying a recipe copies its typed scenario inputs, state pools, and
|
||||||
clauses into the source-owned program. Those branches become editable
|
clauses into the source-owned program. Those branches become editable
|
||||||
authoritative data; the recipe key remains provenance, not a runtime
|
authoritative data; the recipe key remains provenance, not a runtime dependency.
|
||||||
dependency. Adding another recipe appends branches. Replacing a program with a
|
Adding another recipe appends branches. Replacing a program with a recipe
|
||||||
recipe requires confirmation because replacement discards the current branch
|
requires confirmation because replacement discards the current branch graph.
|
||||||
graph.
|
|
||||||
|
|
||||||
A Talent may save an empty `captured_only` program while an author records its
|
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
|
name, category, image, and source description. The registry marks that Talent
|
||||||
`CONFIG REQUIRED`, and calculations exclude it. Changing readiness to `ready`
|
`CONFIG REQUIRED`, and calculations exclude it. Changing readiness to `ready` or
|
||||||
or `requires_assumptions` restores strict branch, numbered-outcome, and
|
`requires_assumptions` restores strict branch, numbered-outcome, and
|
||||||
description-placeholder validation.
|
description-placeholder validation.
|
||||||
|
|
||||||
The program graph uses three child tables:
|
The program graph uses three child tables:
|
||||||
|
|
||||||
- `conditional_effect_state_pools` defines agent- or target-scoped flags,
|
- `conditional_effect_state_pools` defines agent- or target-scoped flags,
|
||||||
counts, stacks, durations, scalar values, marks, and resources. Each pool
|
counts, stacks, durations, scalar values, marks, and resources. Each pool owns
|
||||||
owns its bounds, expiry policy, refresh policy, and best-case value.
|
its bounds, expiry policy, refresh policy, and best-case value.
|
||||||
- `conditional_effect_clauses` listens for one event from the registry. A
|
- `conditional_effect_clauses` listens for one event from the registry. A clause
|
||||||
clause owns a typed predicate, reducer, optional cooldown, optional weight
|
owns a typed predicate, reducer, optional cooldown, optional weight
|
||||||
expression, state mutations, and optional recipe provenance. Reducers convert
|
expression, state mutations, and optional recipe provenance. Reducers convert
|
||||||
event streams into counts, distinct or consecutive matches, sliding windows,
|
event streams into counts, distinct or consecutive matches, sliding windows,
|
||||||
durations, intervals, or derived values. Mutations can set, add, consume,
|
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
|
value basis, optional resource or Attribute, combat timing, optional state
|
||||||
band, and phase: `on_event`, `after_event`, `on_enter`, `on_exit`, or
|
band, and phase: `on_event`, `after_event`, `on_enter`, `on_exit`, or
|
||||||
`while_active`. One typed value or expression exists for every variant
|
`while_active`. One typed value or expression exists for every variant
|
||||||
required by the owning source. The common value models are static values,
|
required by the owning source. The common value models are static values, live
|
||||||
live Attribute values, percentage-normalized Attribute values, event-field values, reducer output,
|
Attribute values, percentage-normalized Attribute values, event-field values,
|
||||||
source-defined scenario values, and OS Protocol upgrade scaling. Restoration
|
reducer output, source-defined scenario values, and OS Protocol upgrade
|
||||||
outcomes may deliver their total immediately or continuously over an explicit
|
scaling. Restoration outcomes may deliver their total immediately or
|
||||||
duration.
|
continuously over an explicit duration.
|
||||||
|
|
||||||
The generic runtime in `src/lib/calculations/mechanic.ts` processes combat events
|
The generic runtime in `src/lib/calculations/mechanic.ts` processes combat
|
||||||
in timestamp order. Predicates decide whether an event enters a clause. Reducers
|
events in timestamp order. Predicates decide whether an event enters a clause.
|
||||||
convert event streams into activations or derived values. The runtime applies
|
Reducers convert event streams into activations or derived values. The runtime
|
||||||
state mutations, enforces pool bounds and expiry, emits outcomes in phase order,
|
applies state mutations, enforces pool bounds and expiry, emits outcomes in
|
||||||
and records telemetry for each activation.
|
phase order, and records telemetry for each activation.
|
||||||
|
|
||||||
Each write to an expiring pool refreshes its timer. When a pool expires,
|
Each write to an expiring pool refreshes its timer. When a pool expires,
|
||||||
`on_exit` outcomes evaluate stack thresholds and per-stack expressions against
|
`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
|
Live Attribute references use a stable Attribute ID and read the authoritative
|
||||||
loadout total that the builder captures for the evaluation pass. Percentage
|
loadout total that the builder captures for the evaluation pass. Percentage
|
||||||
Attributes retain their displayed units, so an expression that applies a
|
Attributes retain their displayed units, so an expression that applies a
|
||||||
coefficient to a percentage divides the Attribute value by 100. Evaluation
|
coefficient to a percentage divides the Attribute value by 100. Evaluation fails
|
||||||
fails when the loadout does not supply a referenced Attribute; the runtime does
|
when the loadout does not supply a referenced Attribute; the runtime does not
|
||||||
not substitute zero.
|
substitute zero.
|
||||||
|
|
||||||
The read-only `combat.currentHealthPercent` reference divides resolved current
|
The read-only `combat.currentHealthPercent` reference divides resolved current
|
||||||
Health by resolved maximum Health and returns a value from 0 through 100. The
|
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
|
Manual mechanic authoring remains the complete authoring path. The AI assistant
|
||||||
produces an optional draft for the existing structured editor. It cannot save,
|
produces an optional draft for the existing structured editor. It cannot save,
|
||||||
publish, approve, or submit game data, and it cannot alter application code.
|
publish, approve, or submit game data, and it cannot alter application code. An
|
||||||
An author may ignore, revise, or replace any generated field through the manual
|
author may ignore, revise, or replace any generated field through the manual
|
||||||
controls.
|
controls.
|
||||||
|
|
||||||
The shared mechanic editor exposes the mechanic assistant for standard and
|
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,
|
grammar change must update the manifest and example, pass the round-trip test,
|
||||||
and increment the prompt contract version before release.
|
and increment the prompt contract version before release.
|
||||||
|
|
||||||
The OpenAI integration uses the Responses API with Structured Outputs, no
|
The OpenAI integration uses the Responses API with Structured Outputs, no tools,
|
||||||
tools, and `store: false`. The application stores the bounded clarification
|
and `store: false`. The application stores the bounded clarification history
|
||||||
history needed for the current drafting session. Requests use a fixed
|
needed for the current drafting session. Requests use a fixed server-configured
|
||||||
server-configured model, a privacy-preserving user safety identifier, input and
|
model, a privacy-preserving user safety identifier, input and output limits,
|
||||||
output limits, timeouts, duplicate-request protection, and per-agent rate
|
timeouts, duplicate-request protection, and per-agent rate limits. The API key
|
||||||
limits. The API key remains on the server.
|
remains on the server.
|
||||||
|
|
||||||
The model returns one structured result:
|
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.
|
reference, and test changes a developer would need to add support.
|
||||||
|
|
||||||
For a mechanic capability gap, the client builds a developer prompt from the
|
For a mechanic capability gap, the client builds a developer prompt from the
|
||||||
exact analyzed wording, clarification transcript, source profile, and
|
exact analyzed wording, clarification transcript, source profile, and structured
|
||||||
structured gap response. The author can copy that prompt without another model
|
gap response. The author can copy that prompt without another model request. The
|
||||||
request. The author can also submit the gap to active full administrators.
|
author can also submit the gap to active full administrators. The submission
|
||||||
The submission endpoint validates bounded fields, confirms that the request ID
|
endpoint validates bounded fields, confirms that the request ID belongs to the
|
||||||
belongs to the author's audited `capability_gap` generation, suppresses repeat
|
author's audited `capability_gap` generation, suppresses repeat submissions,
|
||||||
submissions, sends mandatory ISAC notifications, and appends a
|
sends mandatory ISAC notifications, and appends a `mechanic_gap.submitted` event
|
||||||
`mechanic_gap.submitted` event to the `ai_assistance` audit category. The
|
to the `ai_assistance` audit category. The notification links to the filtered
|
||||||
notification links to the filtered audit evidence. Submission does not mutate
|
audit evidence. Submission does not mutate the mechanic draft or parent
|
||||||
the mechanic draft or parent game-data record.
|
game-data record.
|
||||||
|
|
||||||
The server validates `draft_ready` output against the response schema and the
|
The server validates `draft_ready` output against the response schema and the
|
||||||
same deterministic mechanic parser used by manual authoring. Unknown events,
|
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
|
Successful generation operations append sanitized events under the
|
||||||
`ai_assistance` audit category. Events identify the operator, source type,
|
`ai_assistance` audit category. Events identify the operator, source type,
|
||||||
prompt-context version, model, result type, token usage, request ID, and
|
prompt-context version, model, result type, token usage, request ID, and
|
||||||
validation result. Audit snapshots exclude the API key,
|
validation result. Audit snapshots exclude the API key, environment
|
||||||
environment configuration, hidden model reasoning, authentication data, and
|
configuration, hidden model reasoning, authentication data, and unbounded prompt
|
||||||
unbounded prompt content. Applying a draft changes client state and creates no
|
content. Applying a draft changes client state and creates no server event. The
|
||||||
server event. The owning module's game-data audit event remains the record of
|
owning module's game-data audit event remains the record of any saved mechanic
|
||||||
any saved mechanic change.
|
change.
|
||||||
|
|
||||||
### AI-assisted calculation-revision drafting
|
### 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
|
request into an optional draft revision. It receives the current active or
|
||||||
edited revision, the calculation-stage vocabulary, weapon and Skill profile
|
edited revision, the calculation-stage vocabulary, weapon and Skill profile
|
||||||
contracts, and the active ISAC Attribute catalog. Its versioned prompt,
|
contracts, and the active ISAC Attribute catalog. Its versioned prompt,
|
||||||
structured response schema, validation, and application adapter remain
|
structured response schema, validation, and application adapter remain separate
|
||||||
separate from mechanic drafting.
|
from mechanic drafting.
|
||||||
|
|
||||||
The assistant returns clarification questions, a validated revision draft, or
|
The assistant returns clarification questions, a validated revision draft, or a
|
||||||
a calculation capability gap. A capability gap identifies the missing stage,
|
calculation capability gap. A capability gap identifies the missing stage,
|
||||||
operator, curve, binding, runtime behavior, or validation rule and lists the
|
operator, curve, binding, runtime behavior, or validation rule and lists the
|
||||||
application and test changes needed to support it. The server rejects unknown
|
application and test changes needed to support it. The server rejects unknown
|
||||||
stage keys, locked-position changes, duplicate bindings, invalid Attribute
|
stage keys, locked-position changes, duplicate bindings, invalid Attribute
|
||||||
references, unsupported operators, and malformed curve or cap values through
|
references, unsupported operators, and malformed curve or cap values through the
|
||||||
the deterministic calculation-revision parser.
|
deterministic calculation-revision parser.
|
||||||
|
|
||||||
The `ai.calculation.generate` permission authorizes calculation drafting and
|
The `ai.calculation.generate` permission authorizes calculation drafting and
|
||||||
clarification requests. It does not grant revision mutation or publication.
|
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,
|
toggle, slider, or numeric scenario inputs. Each input records its label,
|
||||||
guidance, bounds when applicable, default value, and best-case value. These
|
guidance, bounds when applicable, default value, and best-case value. These
|
||||||
controls appear only in advanced scenario configuration. Maximum mode supplies
|
controls appear only in advanced scenario configuration. Maximum mode supplies
|
||||||
best-case values. Custom mode uses the values saved with the loadout. A
|
best-case values. Custom mode uses the values saved with the loadout. A mechanic
|
||||||
mechanic with an undefined required assumption remains excluded and reports
|
with an undefined required assumption remains excluded and reports
|
||||||
`REQUIRES_ASSUMPTIONS`.
|
`REQUIRES_ASSUMPTIONS`.
|
||||||
|
|
||||||
Loadout schema version 4 stores one focus, selected tree ranks, nine Skill
|
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
|
- Maximum mode supplies each scenario input's best-case value, selects peak
|
||||||
conditional-effect state, and runs selected abilities on cooldown against
|
conditional-effect state, and runs selected abilities on cooldown against
|
||||||
their configured target caps. The interface hides granular assumptions in
|
their configured target caps. The interface hides granular assumptions in this
|
||||||
this mode.
|
mode.
|
||||||
- Custom mode uses the agent's encounter inputs, effect states, activation
|
- Custom mode uses the agent's encounter inputs, effect states, activation
|
||||||
rates, target counts, and other supported assumptions. A missing required
|
rates, target counts, and other supported assumptions. A missing required
|
||||||
input produces `REQUIRES_ASSUMPTIONS` rather than a fabricated value.
|
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,
|
Shredding damage, direct or periodic delivery, hit or tick counts, cast time,
|
||||||
target cap, and charge behavior. `skill_attribute_calculation_profiles` binds
|
target cap, and charge behavior. `skill_attribute_calculation_profiles` binds
|
||||||
Attributes to the versioned Skill damage and cadence stages. Cooldown Recovery
|
Attributes to the versioned Skill damage and cadence stages. Cooldown Recovery
|
||||||
changes charge and activation readiness. Duration changes the number of
|
changes charge and activation readiness. Duration changes the number of periodic
|
||||||
periodic ticks.
|
ticks.
|
||||||
|
|
||||||
Periodic delivery waits one configured tick interval before its first tick and
|
Periodic delivery waits one configured tick interval before its first tick and
|
||||||
excludes ticks at or after the encounter boundary. The Skill engine reevaluates
|
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
|
The Skill engine uses a dedicated intrinsic ability-damage model. It supports
|
||||||
direct and periodic delivery, critical and multi-shot expectation, target caps,
|
direct and periodic delivery, critical and multi-shot expectation, target caps,
|
||||||
per-ability activation rates, and charge recovery.
|
per-ability activation rates, and charge recovery. Skills do not headshot.
|
||||||
Skills do not headshot. Dynamic Talent and tree-effect windows apply to each
|
Dynamic Talent and tree-effect windows apply to each Skill hit at its delivery
|
||||||
Skill hit at its delivery time. Encounter-triggered Skill modifiers use the
|
time. Encounter-triggered Skill modifiers use the primary weapon timeline until
|
||||||
primary weapon timeline until the builder supports a combined weapon-rotation
|
the builder supports a combined weapon-rotation model.
|
||||||
model.
|
|
||||||
|
|
||||||
The engine returns `INSUFFICIENT_DATA` with missing requirement keys when a
|
The engine returns `INSUFFICIENT_DATA` with missing requirement keys when a
|
||||||
calculation lacks required inputs or an Attribute binding. Callers must render
|
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
|
assisted suggestion action compares ready observations with their predictions
|
||||||
and creates a draft revision for operator review.
|
and creates a draft revision for operator review.
|
||||||
|
|
||||||
Rule activation requires `calculation.publish` and a confirmation.
|
Rule activation requires `calculation.publish` and a confirmation. ISAC records
|
||||||
ISAC records draft creation, draft edits, publication, calibration changes, and
|
draft creation, draft edits, publication, calibration changes, and assisted
|
||||||
assisted suggestions under the `calculation_configuration` audit category.
|
suggestions under the `calculation_configuration` audit category. ISAC data
|
||||||
ISAC data export and environment synchronization include calculation rules,
|
export and environment synchronization include calculation rules, typed mechanic
|
||||||
typed mechanic programs, ability attacks, calibration cases, and their
|
programs, ability attacks, calibration cases, and their relationships in
|
||||||
relationships in dependency order.
|
dependency order.
|
||||||
|
|
||||||
## OS Protocol upgrade expressions and health timing
|
## 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
|
Normalized files live beneath the ignored `data/game-icons/` directory. The
|
||||||
validated `/icons/game-data/[type]/[id]` endpoint serves them with immutable
|
validated `/icons/game-data/[type]/[id]` endpoint serves them with immutable
|
||||||
cache headers and disables MIME sniffing. ISAC save actions process files
|
cache headers and disables MIME sniffing. ISAC save actions process files before
|
||||||
before mutation, apply upload or removal after record validation, and include
|
mutation, apply upload or removal after record validation, and include safe icon
|
||||||
safe icon metadata, rather than image bytes, in audit snapshots.
|
metadata, rather than image bytes, in audit snapshots.
|
||||||
|
|
||||||
Icon metadata is optional. Canonical records and contributor proposals may be
|
Icon metadata is optional. Canonical records and contributor proposals may be
|
||||||
created without an upload, which lets contributors submit values and artwork in
|
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
|
The notification router sends Data Change review items to agents who hold
|
||||||
`data.approve` and Avatar Oversight 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
|
`avatars.review`. Each reviewer chooses Immediate, 15-minute, one-hour, or daily
|
||||||
daily delivery for each authorized queue. Submission creates Immediate alerts;
|
delivery for each authorized queue. Submission creates Immediate alerts;
|
||||||
`oversight_notification_queue` stores consolidated alerts.
|
`oversight_notification_queue` stores consolidated alerts.
|
||||||
|
|
||||||
Production must run the idempotent dispatcher once per minute from one scheduler
|
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
|
* * * * * cd /path/to/shd.build && npm run notifications:oversight:production
|
||||||
```
|
```
|
||||||
|
|
||||||
The dispatcher groups due rows by reviewer, queue, cadence, and delivery
|
The dispatcher groups due rows by reviewer, queue, cadence, and delivery window.
|
||||||
window. Daily rows use the next 10:00 AM wall-clock boundary in
|
Daily rows use the next 10:00 AM wall-clock boundary in `America/New_York`,
|
||||||
`America/New_York`, including daylight-saving transitions. Dispatched rows
|
including daylight-saving transitions. Dispatched rows retain `delivered_at`
|
||||||
retain `delivered_at` evidence, and later queries exclude them. Existing
|
evidence, and later queries exclude them. Existing ISAC notification delivery
|
||||||
ISAC notification delivery preferences determine optional browser and email
|
preferences determine optional browser and email fan-out after the dispatcher
|
||||||
fan-out after the dispatcher creates the consolidated inbox message.
|
creates the consolidated inbox message.
|
||||||
|
|
||||||
## Automated ISAC system messages
|
## 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
|
enabled state, delay in minutes, message content, primary destination, and
|
||||||
structured action callouts.
|
structured action callouts.
|
||||||
|
|
||||||
Password, magic-link, and Discord registration emit `account.created`.
|
Password, magic-link, and Discord registration emit `account.created`. The
|
||||||
The delivery table has a unique message/user/event/event-ID constraint, so the
|
delivery table has a unique message/user/event/event-ID constraint, so the same
|
||||||
same account creation cannot deliver the Welcome Agent message more than once.
|
account creation cannot deliver the Welcome Agent message more than once. The
|
||||||
The default welcome introduces News, Database, Builds, Guides, Builder, ISAC
|
default welcome introduces News, Database, Builds, Guides, Builder, ISAC
|
||||||
Contributor Mode, and the SHD Discord community.
|
Contributor Mode, and the SHD Discord community.
|
||||||
|
|
||||||
The registration request delivers Immediate definitions.
|
The registration request delivers Immediate definitions.
|
||||||
`automated_system_message_deliveries` stores definitions with a delay. Production
|
`automated_system_message_deliveries` stores definitions with a delay.
|
||||||
must run the delayed dispatcher once per minute:
|
Production must run the delayed dispatcher once per minute:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
* * * * * cd /path/to/shd.build && npm run notifications:system:production
|
* * * * * cd /path/to/shd.build && npm run notifications:system:production
|
||||||
|
|||||||
Generated
+4556
-3702
File diff suppressed because it is too large
Load Diff
+92
-80
@@ -1,82 +1,94 @@
|
|||||||
{
|
{
|
||||||
"name": "shd-build",
|
"name": "shd-build",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "dotenvx run -f .env.development -- vite dev --host 0.0.0.0",
|
"dev": "dotenvx run -f .env.development -- vite dev --host 0.0.0.0",
|
||||||
"build:app": "vite build",
|
"build:app": "vite build",
|
||||||
"build:staging": "dotenvx run -f .env.staging -- vite build",
|
"build:staging": "dotenvx run -f .env.staging -- vite build",
|
||||||
"build": "dotenvx run -f .env.production -- vite build",
|
"build": "dotenvx run -f .env.production -- vite build",
|
||||||
"brand:render": "node scripts/render-brand-assets.mjs",
|
"brand:render": "node scripts/render-brand-assets.mjs",
|
||||||
"start": "node build",
|
"start": "node build",
|
||||||
"preview": "node build",
|
"preview": "node build",
|
||||||
"db:migrate": "dotenvx run -f .env.development -- node scripts/db-migrate.mjs",
|
"db:migrate": "dotenvx run -f .env.development -- node scripts/db-migrate.mjs",
|
||||||
"db:migrate:staging": "dotenvx run -f .env.staging -- node scripts/db-migrate.mjs",
|
"db:migrate:staging": "dotenvx run -f .env.staging -- node scripts/db-migrate.mjs",
|
||||||
"db:migrate:production": "dotenvx run -f .env.production -- node scripts/db-migrate.mjs",
|
"db:migrate:production": "dotenvx run -f .env.production -- node scripts/db-migrate.mjs",
|
||||||
"db:seed-users": "dotenvx run -f .env.development -- node scripts/seed-initial-users.mjs",
|
"db:seed-users": "dotenvx run -f .env.development -- node scripts/seed-initial-users.mjs",
|
||||||
"db:seed-users:reset": "dotenvx run -f .env.development -- node scripts/seed-initial-users.mjs --reset-passwords",
|
"db:seed-users:reset": "dotenvx run -f .env.development -- node scripts/seed-initial-users.mjs --reset-passwords",
|
||||||
"db:backup": "dotenvx run -f .env.development -- node scripts/db-backup.mjs",
|
"db:backup": "dotenvx run -f .env.development -- node scripts/db-backup.mjs",
|
||||||
"db:backup:staging": "dotenvx run -f .env.staging -- node scripts/db-backup.mjs",
|
"db:backup:staging": "dotenvx run -f .env.staging -- node scripts/db-backup.mjs",
|
||||||
"db:backup:production": "dotenvx run -f .env.production -- node scripts/db-backup.mjs",
|
"db: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": "dotenvx run -f .env.development -- node scripts/db-restore.mjs",
|
||||||
"db:restore:production": "dotenvx run -f .env.production -- node scripts/db-restore.mjs",
|
"db:restore:production": "dotenvx run -f .env.production -- node scripts/db-restore.mjs",
|
||||||
"isac:export": "dotenvx run -f .env.development -- node scripts/isac-data-export.mjs",
|
"article:export": "dotenvx run -f .env.development -- node scripts/article-transfer.mjs export",
|
||||||
"isac:import": "dotenvx run -f .env.development -- node scripts/isac-data-import.mjs",
|
"isac:export": "dotenvx run -f .env.development -- node scripts/isac-data-export.mjs",
|
||||||
"isac:attributes:import": "dotenvx run -f .env.development -- node scripts/import-resurgence-attributes.mjs",
|
"isac:import": "dotenvx run -f .env.development -- node scripts/isac-data-import.mjs",
|
||||||
"isac:export:staging": "dotenvx run -f .env.staging -- node scripts/isac-data-export.mjs",
|
"isac:attributes:import": "dotenvx run -f .env.development -- node scripts/import-resurgence-attributes.mjs",
|
||||||
"images:export": "dotenvx run -f .env.development -- node scripts/managed-images-export.mjs",
|
"isac:export:staging": "dotenvx run -f .env.staging -- node scripts/isac-data-export.mjs",
|
||||||
"images:import": "dotenvx run -f .env.development -- node scripts/managed-images-import.mjs",
|
"images:export": "dotenvx run -f .env.development -- node scripts/managed-images-export.mjs",
|
||||||
"images:import:staging": "dotenvx run -f .env.staging -- node scripts/managed-images-import.mjs",
|
"images:import": "dotenvx run -f .env.development -- node scripts/managed-images-import.mjs",
|
||||||
"images:import:production": "dotenvx run -f .env.production -- node scripts/managed-images-import.mjs",
|
"images:import:staging": "dotenvx run -f .env.staging -- node scripts/managed-images-import.mjs",
|
||||||
"stage:bootstrap:export": "dotenvx run -f .env.development -- node scripts/stage-bootstrap-export.mjs",
|
"images:import:production": "dotenvx run -f .env.production -- node scripts/managed-images-import.mjs",
|
||||||
"production:bootstrap:export": "dotenvx run -f .env.development -- node scripts/environment-bootstrap-export.mjs production",
|
"stage:bootstrap:export": "dotenvx run -f .env.development -- node scripts/stage-bootstrap-export.mjs",
|
||||||
"notifications:digest": "dotenvx run -f .env.development -- node scripts/notification-digest.mjs",
|
"production:bootstrap:export": "dotenvx run -f .env.development -- node scripts/environment-bootstrap-export.mjs production",
|
||||||
"notifications:digest:production": "dotenvx run -f .env.production -- node scripts/notification-digest.mjs",
|
"notifications:digest": "dotenvx run -f .env.development -- node scripts/notification-digest.mjs",
|
||||||
"notifications:oversight": "dotenvx run -f .env.development -- node scripts/oversight-notification-dispatch.mjs",
|
"notifications:digest:production": "dotenvx run -f .env.production -- node scripts/notification-digest.mjs",
|
||||||
"notifications:oversight:production": "dotenvx run -f .env.production -- node scripts/oversight-notification-dispatch.mjs",
|
"notifications:oversight": "dotenvx run -f .env.development -- node scripts/oversight-notification-dispatch.mjs",
|
||||||
"notifications:system": "dotenvx run -f .env.development -- node scripts/system-notification-dispatch.mjs",
|
"notifications:oversight:production": "dotenvx run -f .env.production -- node scripts/oversight-notification-dispatch.mjs",
|
||||||
"notifications:system:production": "dotenvx run -f .env.production -- node scripts/system-notification-dispatch.mjs",
|
"notifications:system": "dotenvx run -f .env.development -- node scripts/system-notification-dispatch.mjs",
|
||||||
"notifications:seed-test": "dotenvx run -f .env.development -- node scripts/seed-test-notifications.mjs",
|
"notifications:system:production": "dotenvx run -f .env.production -- node scripts/system-notification-dispatch.mjs",
|
||||||
"assets:trim-icons": "node scripts/trim-static-icons.mjs",
|
"notifications:seed-test": "dotenvx run -f .env.development -- node scripts/seed-test-notifications.mjs",
|
||||||
"env:encrypt": "node scripts/env-files.mjs encrypt",
|
"assets:trim-icons": "node scripts/trim-static-icons.mjs",
|
||||||
"env:decrypt": "node scripts/env-files.mjs decrypt",
|
"env:encrypt": "node scripts/env-files.mjs encrypt",
|
||||||
"env:encrypt:development": "dotenvx encrypt -f .env.development",
|
"env:decrypt": "node scripts/env-files.mjs decrypt",
|
||||||
"env:decrypt:development": "dotenvx decrypt -f .env.development",
|
"env:encrypt:development": "dotenvx encrypt -f .env.development",
|
||||||
"env:encrypt:staging": "dotenvx encrypt -f .env.staging",
|
"env:decrypt:development": "dotenvx decrypt -f .env.development",
|
||||||
"env:decrypt:staging": "dotenvx decrypt -f .env.staging",
|
"env:encrypt:staging": "dotenvx encrypt -f .env.staging",
|
||||||
"env:sync:staging-credentials": "dotenvx run -f .env.development -- node scripts/sync-staging-credentials.mjs",
|
"env:decrypt:staging": "dotenvx decrypt -f .env.staging",
|
||||||
"env:encrypt:production": "dotenvx encrypt -f .env.production",
|
"env:sync:staging-credentials": "dotenvx run -f .env.development -- node scripts/sync-staging-credentials.mjs",
|
||||||
"env:decrypt:production": "dotenvx decrypt -f .env.production",
|
"env:encrypt:production": "dotenvx encrypt -f .env.production",
|
||||||
"env:stage": "node scripts/env-files.mjs stage",
|
"env:decrypt:production": "dotenvx decrypt -f .env.production",
|
||||||
"prepare": "svelte-kit sync || echo ''",
|
"env:stage": "node scripts/env-files.mjs stage",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"prepare": "husky",
|
||||||
"test": "vitest run",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
"test": "vitest run",
|
||||||
},
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||||
"devDependencies": {
|
},
|
||||||
"@dotenvx/dotenvx": "^2.17.4",
|
"lint-staged": {
|
||||||
"@sveltejs/adapter-node": "^5.5.7",
|
"**/*": "prettier --write --ignore-unknown"
|
||||||
"@sveltejs/kit": "^2.63.0",
|
},
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
"devDependencies": {
|
||||||
"@types/node": "^26.1.1",
|
"@dotenvx/dotenvx": "^2.17.4",
|
||||||
"@types/nodemailer": "^8.0.1",
|
"@sveltejs/adapter-node": "^5.5.7",
|
||||||
"opentype.js": "^2.0.0",
|
"@sveltejs/kit": "^2.63.0",
|
||||||
"svelte": "^5.56.1",
|
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||||
"svelte-check": "^4.6.0",
|
"@types/node": "^26.1.1",
|
||||||
"typescript": "^6.0.3",
|
"@types/nodemailer": "^8.0.1",
|
||||||
"vite": "^8.0.16",
|
"husky": "^9.1.7",
|
||||||
"vitest": "^4.1.10"
|
"lint-staged": "^16.4.0",
|
||||||
},
|
"opentype.js": "^2.0.0",
|
||||||
"dependencies": {
|
"prettier": "3.9.6",
|
||||||
"@fontsource/barlow": "^5.3.0",
|
"svelte": "^5.56.1",
|
||||||
"@fontsource/source-serif-4": "^5.3.0",
|
"svelte-check": "^4.6.0",
|
||||||
"@simplewebauthn/browser": "^13.3.0",
|
"typescript": "^6.0.3",
|
||||||
"@simplewebauthn/server": "^13.3.2",
|
"vite": "^8.0.16",
|
||||||
"@tursodatabase/database": "^0.7.1",
|
"vitest": "^4.1.10"
|
||||||
"argon2": "^0.45.1",
|
},
|
||||||
"nodemailer": "^9.0.3",
|
"dependencies": {
|
||||||
"openai": "^6.49.0",
|
"@fontsource/barlow": "^5.3.0",
|
||||||
"sharp": "^0.35.3"
|
"@fontsource/source-serif-4": "^5.3.0",
|
||||||
}
|
"@simplewebauthn/browser": "^13.3.0",
|
||||||
|
"@simplewebauthn/server": "^13.3.2",
|
||||||
|
"@tiptap/core": "3.29.2",
|
||||||
|
"@tiptap/extension-link": "3.29.2",
|
||||||
|
"@tiptap/extensions": "3.29.2",
|
||||||
|
"@tiptap/pm": "3.29.2",
|
||||||
|
"@tiptap/starter-kit": "3.29.2",
|
||||||
|
"@tursodatabase/database": "^0.7.1",
|
||||||
|
"argon2": "^0.45.1",
|
||||||
|
"nodemailer": "^9.0.3",
|
||||||
|
"openai": "^6.49.0",
|
||||||
|
"sharp": "^0.35.3"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 Critical Hit Chance",
|
||||||
{"name":"Weapon Damage","unit":"%","type":"Offensive","category":"Weapon","calculationType":"Additive","gearRange":[2,5],"weaponRange":[4,10]},
|
"unit": "%",
|
||||||
{"name":"Weapon Multi-Shot Chance","unit":"%","type":"Offensive","category":"Weapon","calculationType":"Additive","gearRange":[2,5],"weaponRange":[4,10]},
|
"type": "Offensive",
|
||||||
{"name":"Skill Critical Chance","unit":"%","type":"Offensive","category":"Skill","calculationType":"Additive","gearRange":[2.4,6]},
|
"category": "Weapon",
|
||||||
{"name":"Skill Critical Damage","unit":"%","type":"Offensive","category":"Skill","calculationType":"Additive","gearRange":[4,10],"weaponRange":[8,20]},
|
"calculationType": "Additive",
|
||||||
{"name":"Skill Intensity","unit":"%","type":"Offensive","category":"Skill","calculationType":"Additive","gearRange":[2,5],"weaponRange":[4,10]},
|
"gearRange": [2.4, 6]
|
||||||
{"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": "Weapon Critical Hit Damage",
|
||||||
{"name":"Armor","unit":"%","type":"Defensive","category":"Character","calculationType":"Additive","gearRange":[4.66,11.66],"weaponRange":[9.33,23.33]},
|
"unit": "%",
|
||||||
{"name":"Damage Reduction","unit":"%","type":"Defensive","category":"Character","calculationType":"Multiplicative","gearRange":[2,5],"weaponRange":[4,10]},
|
"type": "Offensive",
|
||||||
{"name":"Received Healing","unit":"%","type":"Defensive","category":"Character","calculationType":"Additive","gearRange":[4.8,12],"weaponRange":[9.6,24]},
|
"category": "Weapon",
|
||||||
{"name":"Damage to Elites","unit":"%","type":"Offensive","category":"Character","calculationType":"Additive","gearRange":[2.4,6],"weaponRange":[4.8,12]},
|
"calculationType": "Additive",
|
||||||
{"name":"Signature Ability Charge Efficiency","unit":"%","type":"Offensive","category":"Skill","calculationType":"Additive","gearRange":[4,10]},
|
"gearRange": [4, 10],
|
||||||
{"name":"Skill Duration","unit":"%","type":"Offensive","category":"Skill","calculationType":"Additive","gearRange":[3.2,8]},
|
"weaponRange": [8, 20]
|
||||||
{"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 Damage",
|
||||||
{"name":"Weapon Piercing Damage","unit":"%","type":"Offensive","category":"Weapon","calculationType":"Additive","weaponRange":[4,10]},
|
"unit": "%",
|
||||||
{"name":"Weapon Shredding Damage","unit":"%","type":"Offensive","category":"Weapon","calculationType":"Additive","weaponRange":[4,10]},
|
"type": "Offensive",
|
||||||
{"name":"Skill Blast Damage","unit":"%","type":"Offensive","category":"Skill","calculationType":"Additive","weaponRange":[4,10]},
|
"category": "Weapon",
|
||||||
{"name":"Skill Piercing Damage","unit":"%","type":"Offensive","category":"Skill","calculationType":"Additive","weaponRange":[4,10]},
|
"calculationType": "Additive",
|
||||||
{"name":"Skill Shredding Damage","unit":"%","type":"Offensive","category":"Skill","calculationType":"Additive","weaponRange":[4,10]},
|
"gearRange": [2, 5],
|
||||||
{"name":"Rate of Fire","unit":"%","type":"Offensive","category":"Weapon","calculationType":"Additive"},
|
"weaponRange": [4, 10]
|
||||||
{"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": "Weapon Multi-Shot Chance",
|
||||||
{"name":"Movement Speed","unit":"%","type":"Defensive","category":"Character","calculationType":"Additive"},
|
"unit": "%",
|
||||||
{"name":"Piercing Damage","unit":"","type":"Offensive","category":"Weapon","calculationType":"Additive"},
|
"type": "Offensive",
|
||||||
{"name":"RPM","unit":"Shots","type":"Offensive","category":"Weapon","calculationType":"Additive"},
|
"category": "Weapon",
|
||||||
{"name":"Optimal Range","unit":"m","type":"Offensive","category":"Weapon","calculationType":"Additive"},
|
"calculationType": "Additive",
|
||||||
{"name":"ADS Speed","unit":"s","type":"Offensive","category":"Weapon","calculationType":"Additive"},
|
"gearRange": [2, 5],
|
||||||
{"name":"Reload Speed","unit":"s","type":"Offensive","category":"Weapon","calculationType":"Additive"},
|
"weaponRange": [4, 10]
|
||||||
{"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": "Skill Critical Chance",
|
||||||
{"name":"Vertical Stability","unit":"","type":"Offensive","category":"Weapon","calculationType":"Additive"}
|
"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"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
+25
-17
@@ -3,19 +3,25 @@ import { mkdir, writeFile } from 'node:fs/promises';
|
|||||||
import { basename, dirname, resolve } from 'node:path';
|
import { basename, dirname, resolve } from 'node:path';
|
||||||
import process from 'node:process';
|
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 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 databaseName = basename(databasePath).replace(/\.[^.]+$/, '');
|
||||||
const outputPath = resolve(
|
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 });
|
await mkdir(dirname(outputPath), { recursive: true });
|
||||||
const database = await connect(databasePath);
|
const database = await connect(databasePath);
|
||||||
const tableRows = await (
|
const tableRows = await (
|
||||||
await database.prepare(`
|
await database.prepare(`
|
||||||
SELECT name
|
SELECT name
|
||||||
FROM sqlite_master
|
FROM sqlite_master
|
||||||
WHERE type = 'table'
|
WHERE type = 'table'
|
||||||
@@ -27,23 +33,25 @@ const tableRows = await (
|
|||||||
const tables = [];
|
const tables = [];
|
||||||
let totalRows = 0;
|
let totalRows = 0;
|
||||||
for (const tableRow of tableRows) {
|
for (const tableRow of tableRows) {
|
||||||
const name = String(tableRow.name);
|
const name = String(tableRow.name);
|
||||||
const rows = await (await database.prepare(`SELECT * FROM ${quoteIdentifier(name)}`)).all();
|
const rows = await (
|
||||||
tables.push({ name, rowCount: rows.length, rows });
|
await database.prepare(`SELECT * FROM ${quoteIdentifier(name)}`)
|
||||||
totalRows += rows.length;
|
).all();
|
||||||
|
tables.push({ name, rowCount: rows.length, rows });
|
||||||
|
totalRows += rows.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
const backup = {
|
const backup = {
|
||||||
format: 'shd-build-database-backup',
|
format: 'shd-build-database-backup',
|
||||||
version: 1,
|
version: 1,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
sourceDatabase: basename(databasePath),
|
sourceDatabase: basename(databasePath),
|
||||||
totalRows,
|
totalRows,
|
||||||
tables
|
tables,
|
||||||
};
|
};
|
||||||
await writeFile(outputPath, `${JSON.stringify(backup, null, 2)}\n`, {
|
await writeFile(outputPath, `${JSON.stringify(backup, null, 2)}\n`, {
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
flag: 'wx'
|
flag: 'wx',
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`Backed up ${totalRows} rows from ${tables.length} tables.`);
|
console.log(`Backed up ${totalRows} rows from ${tables.length} tables.`);
|
||||||
|
|||||||
+2129
-1187
File diff suppressed because it is too large
Load Diff
+41
-30
@@ -5,53 +5,64 @@ import process from 'node:process';
|
|||||||
|
|
||||||
const backupArgument = process.argv[2];
|
const backupArgument = process.argv[2];
|
||||||
if (!backupArgument) {
|
if (!backupArgument) {
|
||||||
console.error('Usage: npm run db:restore -- <backup-file>');
|
console.error('Usage: npm run db:restore -- <backup-file>');
|
||||||
process.exit(2);
|
process.exit(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
const backupPath = resolve(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'));
|
const backup = JSON.parse(await readFile(backupPath, 'utf8'));
|
||||||
if (backup?.format !== 'shd-build-database-backup' || backup?.version !== 1 || !Array.isArray(backup.tables)) {
|
if (
|
||||||
throw new Error('The selected file is not a supported SHD.build database backup.');
|
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);
|
const database = await connect(databasePath);
|
||||||
|
|
||||||
// Restores are deliberately limited to empty databases. This prevents an
|
// Restores are deliberately limited to empty databases. This prevents an
|
||||||
// accidental merge from overwriting contributor-entered records.
|
// accidental merge from overwriting contributor-entered records.
|
||||||
for (const table of backup.tables) {
|
for (const table of backup.tables) {
|
||||||
const existing = await (
|
const existing = await (
|
||||||
await database.prepare(`SELECT COUNT(*) AS count FROM ${quoteIdentifier(table.name)}`)
|
await database.prepare(
|
||||||
).get();
|
`SELECT COUNT(*) AS count FROM ${quoteIdentifier(table.name)}`,
|
||||||
if (Number(existing?.count) > 0) {
|
)
|
||||||
throw new Error(
|
).get();
|
||||||
`Restore refused: table "${table.name}" is not empty. Use a new migrated database.`
|
if (Number(existing?.count) > 0) {
|
||||||
);
|
throw new Error(
|
||||||
}
|
`Restore refused: table "${table.name}" is not empty. Use a new migrated database.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let restoredRows = 0;
|
let restoredRows = 0;
|
||||||
await database.exec('BEGIN');
|
await database.exec('BEGIN');
|
||||||
try {
|
try {
|
||||||
for (const table of backup.tables) {
|
for (const table of backup.tables) {
|
||||||
for (const row of table.rows) {
|
for (const row of table.rows) {
|
||||||
const columns = Object.keys(row);
|
const columns = Object.keys(row);
|
||||||
if (!columns.length) continue;
|
if (!columns.length) continue;
|
||||||
const columnSql = columns.map(quoteIdentifier).join(', ');
|
const columnSql = columns.map(quoteIdentifier).join(', ');
|
||||||
const placeholders = columns.map(() => '?').join(', ');
|
const placeholders = columns.map(() => '?').join(', ');
|
||||||
const insert = await database.prepare(
|
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]));
|
await insert.run(...columns.map((column) => row[column]));
|
||||||
restoredRows++;
|
restoredRows++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await database.exec('COMMIT');
|
await database.exec('COMMIT');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await database.exec('ROLLBACK');
|
await database.exec('ROLLBACK');
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Restored ${restoredRows} rows into ${databasePath}.`);
|
console.log(`Restored ${restoredRows} rows into ${databasePath}.`);
|
||||||
|
|||||||
+87
-70
@@ -2,30 +2,30 @@ import { argon2id, hash } from 'argon2';
|
|||||||
import { randomBytes, randomUUID } from 'node:crypto';
|
import { randomBytes, randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
export const defaultUsers = [
|
export const defaultUsers = [
|
||||||
{
|
{
|
||||||
username: 'Aewyn',
|
username: 'Aewyn',
|
||||||
role: 'user',
|
role: 'user',
|
||||||
passwordEnvironmentKey: 'DEFAULT_ADMIN_PASSWORD'
|
passwordEnvironmentKey: 'DEFAULT_ADMIN_PASSWORD',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
username: 'Quartermaster',
|
username: 'Quartermaster',
|
||||||
role: 'user',
|
role: 'user',
|
||||||
passwordEnvironmentKey: 'DEFAULT_USER_PASSWORD'
|
passwordEnvironmentKey: 'DEFAULT_USER_PASSWORD',
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function temporaryPassword() {
|
function temporaryPassword() {
|
||||||
// 120 bits of entropy encoded without punctuation that is awkward to paste.
|
// 120 bits of entropy encoded without punctuation that is awkward to paste.
|
||||||
return randomBytes(15).toString('base64url');
|
return randomBytes(15).toString('base64url');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function secureHash(password) {
|
async function secureHash(password) {
|
||||||
return hash(password, {
|
return hash(password, {
|
||||||
type: argon2id,
|
type: argon2id,
|
||||||
memoryCost: 19_456,
|
memoryCost: 19_456,
|
||||||
timeCost: 2,
|
timeCost: 2,
|
||||||
parallelism: 1
|
parallelism: 1,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,61 +33,78 @@ async function secureHash(password) {
|
|||||||
* users keep their IDs and related records but receive new password hashes.
|
* users keep their IDs and related records but receive new password hashes.
|
||||||
*/
|
*/
|
||||||
export async function initializeDefaultUsers(database, options = {}) {
|
export async function initializeDefaultUsers(database, options = {}) {
|
||||||
const resetPasswords = options.resetPasswords === true;
|
const resetPasswords = options.resetPasswords === true;
|
||||||
const findUser = await database.prepare(
|
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(
|
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(
|
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 = [];
|
const credentials = [];
|
||||||
|
|
||||||
for (const definition of defaultUsers) {
|
for (const definition of defaultUsers) {
|
||||||
const existing = await findUser.get(definition.username, definition.username);
|
const existing = await findUser.get(
|
||||||
if (existing && !resetPasswords) {
|
definition.username,
|
||||||
console.log(`${definition.username}: already exists (${existing.role}); left unchanged`);
|
definition.username,
|
||||||
continue;
|
);
|
||||||
}
|
if (existing && !resetPasswords) {
|
||||||
|
console.log(
|
||||||
|
`${definition.username}: already exists (${existing.role}); left unchanged`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const configuredPassword = process.env[definition.passwordEnvironmentKey]?.trim();
|
const configuredPassword =
|
||||||
const password = configuredPassword || temporaryPassword();
|
process.env[definition.passwordEnvironmentKey]?.trim();
|
||||||
if (password.length < 12 || password.length > 128) {
|
const password = configuredPassword || temporaryPassword();
|
||||||
throw new Error(`${definition.passwordEnvironmentKey} must be between 12 and 128 characters.`);
|
if (password.length < 12 || password.length > 128) {
|
||||||
}
|
throw new Error(
|
||||||
const passwordHash = await secureHash(password);
|
`${definition.passwordEnvironmentKey} must be between 12 and 128 characters.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const passwordHash = await secureHash(password);
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await updateCredential.run(definition.username, passwordHash, String(existing.role), existing.id);
|
await updateCredential.run(
|
||||||
console.log(`${definition.username}: credentials reset; role and related data preserved`);
|
definition.username,
|
||||||
} else {
|
passwordHash,
|
||||||
await insertUser.run(
|
String(existing.role),
|
||||||
randomUUID(),
|
existing.id,
|
||||||
definition.username,
|
);
|
||||||
definition.username,
|
console.log(
|
||||||
passwordHash,
|
`${definition.username}: credentials reset; role and related data preserved`,
|
||||||
definition.role,
|
);
|
||||||
new Date().toISOString()
|
} else {
|
||||||
);
|
await insertUser.run(
|
||||||
console.log(`${definition.username}: default account created`);
|
randomUUID(),
|
||||||
}
|
definition.username,
|
||||||
credentials.push({
|
definition.username,
|
||||||
username: definition.username,
|
passwordHash,
|
||||||
role: existing ? String(existing.role) : definition.role,
|
definition.role,
|
||||||
password,
|
new Date().toISOString(),
|
||||||
generated: !configuredPassword
|
);
|
||||||
});
|
console.log(`${definition.username}: default account created`);
|
||||||
}
|
}
|
||||||
|
credentials.push({
|
||||||
|
username: definition.username,
|
||||||
|
role: existing ? String(existing.role) : definition.role,
|
||||||
|
password,
|
||||||
|
generated: !configuredPassword,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (credentials.length) {
|
if (credentials.length) {
|
||||||
console.log('\nDefault user credentials (shown once):');
|
console.log('\nDefault user credentials (shown once):');
|
||||||
for (const credential of credentials) {
|
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');
|
);
|
||||||
}
|
}
|
||||||
|
console.log('Change generated passwords after the first sign-in.\n');
|
||||||
|
}
|
||||||
|
|
||||||
return credentials;
|
return credentials;
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-9
@@ -8,25 +8,28 @@ const gitCommand = process.platform === 'win32' ? 'git.exe' : 'git';
|
|||||||
const dotenvxCli = resolve('node_modules/@dotenvx/dotenvx/src/cli/dotenvx.js');
|
const dotenvxCli = resolve('node_modules/@dotenvx/dotenvx/src/cli/dotenvx.js');
|
||||||
|
|
||||||
function run(command, args) {
|
function run(command, args) {
|
||||||
const result = spawnSync(command, args, { cwd: process.cwd(), stdio: 'inherit' });
|
const result = spawnSync(command, args, {
|
||||||
if (result.error) throw result.error;
|
cwd: process.cwd(),
|
||||||
if (result.status !== 0) process.exit(result.status ?? 1);
|
stdio: 'inherit',
|
||||||
|
});
|
||||||
|
if (result.error) throw result.error;
|
||||||
|
if (result.status !== 0) process.exit(result.status ?? 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
function dotenvx(command, file) {
|
function dotenvx(command, file) {
|
||||||
run(process.execPath, [dotenvxCli, command, '-f', file]);
|
run(process.execPath, [dotenvxCli, command, '-f', file]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!['encrypt', 'decrypt', 'stage'].includes(action)) {
|
if (!['encrypt', 'decrypt', 'stage'].includes(action)) {
|
||||||
console.error('Usage: node scripts/env-files.mjs <encrypt|decrypt|stage>');
|
console.error('Usage: node scripts/env-files.mjs <encrypt|decrypt|stage>');
|
||||||
process.exit(2);
|
process.exit(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
dotenvx(action === 'decrypt' ? 'decrypt' : 'encrypt', file);
|
dotenvx(action === 'decrypt' ? 'decrypt' : 'encrypt', file);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === 'stage') {
|
if (action === 'stage') {
|
||||||
run(gitCommand, ['add', '--', ...files]);
|
run(gitCommand, ['add', '--', ...files]);
|
||||||
console.log('Encrypted and staged environment files.');
|
console.log('Encrypted and staged environment files.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,50 +5,81 @@ import process from 'node:process';
|
|||||||
|
|
||||||
const target = process.argv[2];
|
const target = process.argv[2];
|
||||||
if (!['staging', 'production'].includes(target)) {
|
if (!['staging', 'production'].includes(target)) {
|
||||||
console.error('Usage: node scripts/environment-bootstrap-export.mjs <staging|production> [output.json]');
|
console.error(
|
||||||
process.exit(2);
|
'Usage: node scripts/environment-bootstrap-export.mjs <staging|production> [output.json]',
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
}
|
}
|
||||||
const outputArgument = process.argv[3];
|
const outputArgument = process.argv[3];
|
||||||
const timestamp = new Date().toISOString().replaceAll(':', '-').replace(/\.\d{3}Z$/, 'Z');
|
const timestamp = new Date()
|
||||||
const outputPath = resolve(outputArgument || `./data/backups/shd-${target}-bootstrap-${timestamp}.json`);
|
.toISOString()
|
||||||
|
.replaceAll(':', '-')
|
||||||
|
.replace(/\.\d{3}Z$/, 'Z');
|
||||||
|
const outputPath = resolve(
|
||||||
|
outputArgument || `./data/backups/shd-${target}-bootstrap-${timestamp}.json`,
|
||||||
|
);
|
||||||
const temporaryBackupPath = `${outputPath}.database.json`;
|
const temporaryBackupPath = `${outputPath}.database.json`;
|
||||||
const databaseBackup = spawnSync(process.execPath, ['scripts/db-backup.mjs', temporaryBackupPath], {
|
const databaseBackup = spawnSync(
|
||||||
cwd: process.cwd(),
|
process.execPath,
|
||||||
stdio: 'inherit',
|
['scripts/db-backup.mjs', temporaryBackupPath],
|
||||||
env: process.env
|
{
|
||||||
});
|
cwd: process.cwd(),
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: process.env,
|
||||||
|
},
|
||||||
|
);
|
||||||
if (databaseBackup.error) throw databaseBackup.error;
|
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) {
|
async function collectFiles(directory, pattern) {
|
||||||
try {
|
try {
|
||||||
const names = (await readdir(directory)).filter((name) => pattern.test(name)).sort();
|
const names = (await readdir(directory))
|
||||||
return await Promise.all(names.map(async (name) => ({
|
.filter((name) => pattern.test(name))
|
||||||
name,
|
.sort();
|
||||||
contentBase64: (await readFile(join(directory, name))).toString('base64')
|
return await Promise.all(
|
||||||
})));
|
names.map(async (name) => ({
|
||||||
} catch (error) {
|
name,
|
||||||
if (error?.code === 'ENOENT') return [];
|
contentBase64: (await readFile(join(directory, name))).toString(
|
||||||
throw error;
|
'base64',
|
||||||
}
|
),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code === 'ENOENT') return [];
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const database = JSON.parse(await readFile(temporaryBackupPath, 'utf8'));
|
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 avatars = await collectFiles(
|
||||||
const gameIcons = await collectFiles(resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons'), /^[a-z_]+-[0-9a-f-]{36}\.webp$/i);
|
resolve(process.env.TDR_AVATAR_STORAGE_PATH || './data/avatars'),
|
||||||
const bundle = {
|
/^[0-9a-f-]{36}\.webp$/i,
|
||||||
format: 'shd-build-environment-bootstrap',
|
);
|
||||||
version: 1,
|
const gameIcons = await collectFiles(
|
||||||
target,
|
resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons'),
|
||||||
createdAt: new Date().toISOString(),
|
/^[a-z_]+-[0-9a-f-]{36}\.webp$/i,
|
||||||
database,
|
);
|
||||||
files: { avatars, gameIcons }
|
const bundle = {
|
||||||
};
|
format: 'shd-build-environment-bootstrap',
|
||||||
await mkdir(dirname(outputPath), { recursive: true });
|
version: 1,
|
||||||
await writeFile(outputPath, `${JSON.stringify(bundle, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' });
|
target,
|
||||||
console.log(`${target} bootstrap bundle includes ${avatars.length} avatars and ${gameIcons.length} managed image assets.`);
|
createdAt: new Date().toISOString(),
|
||||||
console.log(`Bootstrap bundle: ${outputPath}`);
|
database,
|
||||||
|
files: { avatars, gameIcons },
|
||||||
|
};
|
||||||
|
await mkdir(dirname(outputPath), { recursive: true });
|
||||||
|
await writeFile(outputPath, `${JSON.stringify(bundle, null, 2)}\n`, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
flag: 'wx',
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`${target} bootstrap bundle includes ${avatars.length} avatars and ${gameIcons.length} managed image assets.`,
|
||||||
|
);
|
||||||
|
console.log(`Bootstrap bundle: ${outputPath}`);
|
||||||
} finally {
|
} finally {
|
||||||
await rm(temporaryBackupPath, { force: true });
|
await rm(temporaryBackupPath, { force: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,112 +4,178 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|||||||
import { join, resolve } from 'node:path';
|
import { join, resolve } from 'node:path';
|
||||||
import process from 'node:process';
|
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 targetIndex = process.argv.indexOf('--target');
|
||||||
const target = targetIndex >= 0 ? process.argv[targetIndex + 1] : '';
|
const target = targetIndex >= 0 ? process.argv[targetIndex + 1] : '';
|
||||||
const allowed = target === 'staging'
|
const allowed =
|
||||||
? process.env.ALLOW_STAGING_BOOTSTRAP === 'true'
|
target === 'staging'
|
||||||
: target === 'production'
|
? process.env.ALLOW_STAGING_BOOTSTRAP === 'true'
|
||||||
? process.env.ALLOW_PRODUCTION_BOOTSTRAP === 'true'
|
: target === 'production'
|
||||||
: false;
|
? process.env.ALLOW_PRODUCTION_BOOTSTRAP === 'true'
|
||||||
if (!bundleArgument || !process.argv.includes('--confirm-empty-target-replace') || !allowed) {
|
: false;
|
||||||
console.error('Environment bootstrap requires the matching ALLOW_*_BOOTSTRAP=true guard and:');
|
if (
|
||||||
console.error('node scripts/environment-bootstrap-import.mjs <bundle.json> --target <staging|production> --confirm-empty-target-replace');
|
!bundleArgument ||
|
||||||
process.exit(2);
|
!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 bundlePath = resolve(bundleArgument);
|
||||||
const bundle = JSON.parse(await readFile(bundlePath, 'utf8'));
|
const bundle = JSON.parse(await readFile(bundlePath, 'utf8'));
|
||||||
if (bundle?.format !== 'shd-build-environment-bootstrap' || bundle?.version !== 1 || bundle?.target !== target
|
if (
|
||||||
|| bundle.database?.format !== 'shd-build-database-backup' || bundle.database?.version !== 1
|
bundle?.format !== 'shd-build-environment-bootstrap' ||
|
||||||
|| !Array.isArray(bundle.database.tables) || !Array.isArray(bundle.files?.avatars)
|
bundle?.version !== 1 ||
|
||||||
|| !Array.isArray(bundle.files?.gameIcons)) {
|
bundle?.target !== target ||
|
||||||
throw new Error('The selected file is not a supported SHD.build bootstrap bundle for the selected 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) {
|
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 (
|
||||||
throw new Error('The bootstrap bundle contains an invalid avatar file.');
|
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) {
|
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') {
|
if (
|
||||||
throw new Error('The bootstrap bundle contains an invalid game-data icon file.');
|
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'], {
|
const safetyBackup = spawnSync(process.execPath, ['scripts/db-backup.mjs'], {
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
env: process.env
|
env: process.env,
|
||||||
});
|
});
|
||||||
if (safetyBackup.error) throw safetyBackup.error;
|
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);
|
const database = await connect(databasePath);
|
||||||
for (const tableName of ['users', 'builds', 'audit_log', 'data_change_proposals']) {
|
for (const tableName of [
|
||||||
const row = await (await database.prepare(`SELECT COUNT(*) AS count FROM "${tableName}"`)).get();
|
'users',
|
||||||
if (Number(row?.count ?? 0) > 0) {
|
'builds',
|
||||||
throw new Error(`${target} bootstrap refused: target table "${tableName}" is not empty.`);
|
'audit_log',
|
||||||
}
|
'data_change_proposals',
|
||||||
|
]) {
|
||||||
|
const row = await (
|
||||||
|
await database.prepare(`SELECT COUNT(*) AS count FROM "${tableName}"`)
|
||||||
|
).get();
|
||||||
|
if (Number(row?.count ?? 0) > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`${target} bootstrap refused: target table "${tableName}" is not empty.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetTables = await (await database.prepare(`
|
const targetTables = await (
|
||||||
|
await database.prepare(`
|
||||||
SELECT name FROM sqlite_master
|
SELECT name FROM sqlite_master
|
||||||
WHERE type='table' AND name NOT LIKE 'sqlite_%'
|
WHERE type='table' AND name NOT LIKE 'sqlite_%'
|
||||||
ORDER BY rowid
|
ORDER BY rowid
|
||||||
`)).all();
|
`)
|
||||||
|
).all();
|
||||||
const targetNames = new Set(targetTables.map((row) => String(row.name)));
|
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) {
|
for (const row of targetTables) {
|
||||||
if (!sourceNames.has(String(row.name))) {
|
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
|
const sourceOnlyTables = bundle.database.tables
|
||||||
.map((table) => String(table.name))
|
.map((table) => String(table.name))
|
||||||
.filter((name) => !targetNames.has(name));
|
.filter((name) => !targetNames.has(name));
|
||||||
if (sourceOnlyTables.length) {
|
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;
|
let restoredRows = 0;
|
||||||
await database.exec('PRAGMA foreign_keys=OFF');
|
await database.exec('PRAGMA foreign_keys=OFF');
|
||||||
await database.exec('BEGIN');
|
await database.exec('BEGIN');
|
||||||
try {
|
try {
|
||||||
for (const row of [...targetTables].reverse()) {
|
for (const row of [...targetTables].reverse()) {
|
||||||
await database.exec(`DELETE FROM ${quoteIdentifier(row.name)}`);
|
await database.exec(`DELETE FROM ${quoteIdentifier(row.name)}`);
|
||||||
}
|
}
|
||||||
for (const table of bundle.database.tables) {
|
for (const table of bundle.database.tables) {
|
||||||
for (const row of table.rows) {
|
for (const row of table.rows) {
|
||||||
const columns = Object.keys(row);
|
const columns = Object.keys(row);
|
||||||
if (!columns.length) continue;
|
if (!columns.length) continue;
|
||||||
const insert = await database.prepare(
|
const insert = await database.prepare(
|
||||||
`INSERT INTO ${quoteIdentifier(table.name)} (${columns.map(quoteIdentifier).join(', ')})
|
`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]));
|
await insert.run(...columns.map((column) => row[column]));
|
||||||
restoredRows++;
|
restoredRows++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await database.exec('COMMIT');
|
await database.exec('COMMIT');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await database.exec('ROLLBACK');
|
await database.exec('ROLLBACK');
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
await database.exec('PRAGMA foreign_keys=ON');
|
await database.exec('PRAGMA foreign_keys=ON');
|
||||||
}
|
}
|
||||||
|
|
||||||
const avatarDirectory = resolve(process.env.TDR_AVATAR_STORAGE_PATH || './data/avatars');
|
const avatarDirectory = resolve(
|
||||||
const gameIconDirectory = resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons');
|
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(avatarDirectory, { recursive: true });
|
||||||
await mkdir(gameIconDirectory, { recursive: true });
|
await mkdir(gameIconDirectory, { recursive: true });
|
||||||
for (const file of bundle.files.avatars) {
|
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) {
|
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}`);
|
console.log(`Source bundle: ${bundlePath}`);
|
||||||
|
|||||||
@@ -4,121 +4,186 @@ import { randomUUID } from 'node:crypto';
|
|||||||
import { isDeepStrictEqual } from 'node:util';
|
import { isDeepStrictEqual } from 'node:util';
|
||||||
import { resolve } from 'node:path';
|
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'));
|
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();
|
const identities = new Set();
|
||||||
for (const record of records) {
|
for (const record of records) {
|
||||||
const identity = `${String(record.name).normalize('NFKC').trim().toLocaleLowerCase()}\u0000${String(record.unit ?? '')}`;
|
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))
|
||||||
identities.add(identity);
|
throw new Error(
|
||||||
if (!['Offensive', 'Defensive'].includes(record.type)
|
`Duplicate Attribute identity in import source: ${record.name} (${record.unit || 'flat'})`,
|
||||||
|| !['Weapon', 'Skill', 'Character'].includes(record.category)
|
);
|
||||||
|| !['Additive', 'Multiplicative'].includes(record.calculationType)
|
identities.add(identity);
|
||||||
|| !['', '%', 's', 'Shots', 'm'].includes(record.unit ?? '')) {
|
if (
|
||||||
throw new Error(`Invalid Attribute classification in import source: ${record.name}`);
|
!['Offensive', 'Defensive'].includes(record.type) ||
|
||||||
}
|
!['Weapon', 'Skill', 'Character'].includes(record.category) ||
|
||||||
for (const range of [record.gearRange, record.weaponRange]) {
|
!['Additive', 'Multiplicative'].includes(record.calculationType) ||
|
||||||
if (range === undefined) continue;
|
!['', '%', 's', 'Shots', 'm'].includes(record.unit ?? '')
|
||||||
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}`);
|
throw new Error(
|
||||||
}
|
`Invalid Attribute classification in import source: ${record.name}`,
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
for (const range of [record.gearRange, record.weaponRange]) {
|
||||||
|
if (range === undefined) continue;
|
||||||
|
if (
|
||||||
|
!Array.isArray(range) ||
|
||||||
|
range.length !== 2 ||
|
||||||
|
!range.every(Number.isFinite) ||
|
||||||
|
range[0] > range[1]
|
||||||
|
) {
|
||||||
|
throw new Error(`Invalid source range in import source: ${record.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const vite = await createServer({ server: { middlewareMode: true }, appType: 'custom', logLevel: 'error' });
|
const vite = await createServer({
|
||||||
|
server: { middlewareMode: true },
|
||||||
|
appType: 'custom',
|
||||||
|
logLevel: 'error',
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
const { getDatabase } = await vite.ssrLoadModule('/src/lib/server/database.ts');
|
const { getDatabase } = await vite.ssrLoadModule(
|
||||||
const { auditRecord, recordIsacAudit } = await vite.ssrLoadModule('/src/lib/server/audit.ts');
|
'/src/lib/server/database.ts',
|
||||||
const database = await getDatabase();
|
);
|
||||||
const actor = await (await database.prepare(`SELECT users.id FROM users
|
const { auditRecord, recordIsacAudit } = await vite.ssrLoadModule(
|
||||||
|
'/src/lib/server/audit.ts',
|
||||||
|
);
|
||||||
|
const database = await getDatabase();
|
||||||
|
const actor = await (
|
||||||
|
await database.prepare(`SELECT users.id FROM users
|
||||||
LEFT JOIN user_profiles profile ON profile.user_id=users.id
|
LEFT JOIN user_profiles profile ON profile.user_id=users.id
|
||||||
LEFT JOIN user_roles link ON link.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'
|
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
|
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');
|
LIMIT 1`)
|
||||||
if (!actor?.id) throw new Error('No administrator account is available to own the Attribute import audit events.');
|
).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 created = 0;
|
||||||
let updated = 0;
|
let updated = 0;
|
||||||
let unchanged = 0;
|
let unchanged = 0;
|
||||||
await database.exec('BEGIN');
|
await database.exec('BEGIN');
|
||||||
try {
|
try {
|
||||||
for (const record of records) {
|
for (const record of records) {
|
||||||
const name = String(record.name).normalize('NFKC').trim();
|
const name = String(record.name).normalize('NFKC').trim();
|
||||||
const unit = String(record.unit ?? '');
|
const unit = String(record.unit ?? '');
|
||||||
const existing = await (await database.prepare(
|
const existing = await (
|
||||||
'SELECT * FROM stat_attributes WHERE name=? COLLATE NOCASE AND unit=?'
|
await database.prepare(
|
||||||
)).get(name, unit);
|
'SELECT * FROM stat_attributes WHERE name=? COLLATE NOCASE AND unit=?',
|
||||||
const id = String(existing?.id ?? randomUUID());
|
)
|
||||||
const now = new Date().toISOString();
|
).get(name, unit);
|
||||||
const next = {
|
const id = String(existing?.id ?? randomUUID());
|
||||||
...(existing ?? {}),
|
const now = new Date().toISOString();
|
||||||
id,
|
const next = {
|
||||||
name,
|
...(existing ?? {}),
|
||||||
description: existing?.description ?? null,
|
id,
|
||||||
type: record.type,
|
name,
|
||||||
category: record.category,
|
description: existing?.description ?? null,
|
||||||
calculation_type: record.calculationType,
|
type: record.type,
|
||||||
unit,
|
category: record.category,
|
||||||
gear_min: record.gearRange?.[0] ?? existing?.gear_min ?? null,
|
calculation_type: record.calculationType,
|
||||||
gear_max: record.gearRange?.[1] ?? existing?.gear_max ?? null,
|
unit,
|
||||||
gear_reference_level: Number(existing?.gear_reference_level ?? 50),
|
gear_min: record.gearRange?.[0] ?? existing?.gear_min ?? null,
|
||||||
weapon_min: record.weaponRange?.[0] ?? existing?.weapon_min ?? null,
|
gear_max: record.gearRange?.[1] ?? existing?.gear_max ?? null,
|
||||||
weapon_max: record.weaponRange?.[1] ?? existing?.weapon_max ?? null,
|
gear_reference_level: Number(existing?.gear_reference_level ?? 50),
|
||||||
weapon_reference_level: Number(existing?.weapon_reference_level ?? 50),
|
weapon_min: record.weaponRange?.[0] ?? existing?.weapon_min ?? null,
|
||||||
skill_chip_min: existing?.skill_chip_min ?? null,
|
weapon_max: record.weaponRange?.[1] ?? existing?.weapon_max ?? null,
|
||||||
skill_chip_max: existing?.skill_chip_max ?? null,
|
weapon_reference_level: Number(existing?.weapon_reference_level ?? 50),
|
||||||
cap_value: existing?.cap_value ?? null,
|
skill_chip_min: existing?.skill_chip_min ?? null,
|
||||||
active: 1,
|
skill_chip_max: existing?.skill_chip_max ?? null,
|
||||||
created_at: String(existing?.created_at ?? now),
|
cap_value: existing?.cap_value ?? null,
|
||||||
updated_at: now
|
active: 1,
|
||||||
};
|
created_at: String(existing?.created_at ?? now),
|
||||||
const comparableExisting = existing ? { ...existing, updated_at: now } : null;
|
updated_at: now,
|
||||||
if (comparableExisting && isDeepStrictEqual(comparableExisting, next)) {
|
};
|
||||||
unchanged++;
|
const comparableExisting = existing
|
||||||
continue;
|
? { ...existing, updated_at: now }
|
||||||
}
|
: null;
|
||||||
const before = existing ? await auditRecord('stat_attributes', id) : null;
|
if (comparableExisting && isDeepStrictEqual(comparableExisting, next)) {
|
||||||
if (existing) {
|
unchanged++;
|
||||||
await (await database.prepare(`UPDATE stat_attributes SET
|
continue;
|
||||||
|
}
|
||||||
|
const before = existing ? await auditRecord('stat_attributes', id) : null;
|
||||||
|
if (existing) {
|
||||||
|
await (
|
||||||
|
await database.prepare(`UPDATE stat_attributes SET
|
||||||
name=?,description=?,type=?,category=?,calculation_type=?,unit=?,
|
name=?,description=?,type=?,category=?,calculation_type=?,unit=?,
|
||||||
gear_min=?,gear_max=?,weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?,
|
gear_min=?,gear_max=?,weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?,
|
||||||
cap_value=?,active=1,updated_at=? WHERE id=?`)).run(
|
cap_value=?,active=1,updated_at=? WHERE id=?`)
|
||||||
next.name, next.description, next.type, next.category, next.calculation_type, next.unit,
|
).run(
|
||||||
next.gear_min, next.gear_max, next.weapon_min, next.weapon_max, next.skill_chip_min,
|
next.name,
|
||||||
next.skill_chip_max, next.cap_value, now, id
|
next.description,
|
||||||
);
|
next.type,
|
||||||
updated++;
|
next.category,
|
||||||
} else {
|
next.calculation_type,
|
||||||
await (await database.prepare(`INSERT INTO stat_attributes(
|
next.unit,
|
||||||
|
next.gear_min,
|
||||||
|
next.gear_max,
|
||||||
|
next.weapon_min,
|
||||||
|
next.weapon_max,
|
||||||
|
next.skill_chip_min,
|
||||||
|
next.skill_chip_max,
|
||||||
|
next.cap_value,
|
||||||
|
now,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
updated++;
|
||||||
|
} else {
|
||||||
|
await (
|
||||||
|
await database.prepare(`INSERT INTO stat_attributes(
|
||||||
id,name,description,type,category,calculation_type,unit,
|
id,name,description,type,category,calculation_type,unit,
|
||||||
gear_min,gear_max,gear_reference_level,weapon_min,weapon_max,weapon_reference_level,
|
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
|
skill_chip_min,skill_chip_max,cap_value,active,created_at,updated_at
|
||||||
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)`)).run(
|
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)`)
|
||||||
id, next.name, next.description, next.type, next.category, next.calculation_type, next.unit,
|
).run(
|
||||||
next.gear_min, next.gear_max, next.gear_reference_level, next.weapon_min, next.weapon_max,
|
id,
|
||||||
next.weapon_reference_level, next.skill_chip_min, next.skill_chip_max, next.cap_value, now, now
|
next.name,
|
||||||
);
|
next.description,
|
||||||
created++;
|
next.type,
|
||||||
}
|
next.category,
|
||||||
await recordIsacAudit({
|
next.calculation_type,
|
||||||
actorId: String(actor.id),
|
next.unit,
|
||||||
category: 'game_data',
|
next.gear_min,
|
||||||
action: existing ? 'attribute.updated' : 'attribute.created',
|
next.gear_max,
|
||||||
entityId: id,
|
next.gear_reference_level,
|
||||||
before,
|
next.weapon_min,
|
||||||
after: await auditRecord('stat_attributes', id)
|
next.weapon_max,
|
||||||
});
|
next.weapon_reference_level,
|
||||||
}
|
next.skill_chip_min,
|
||||||
await database.exec('COMMIT');
|
next.skill_chip_max,
|
||||||
} catch (error) {
|
next.cap_value,
|
||||||
await database.exec('ROLLBACK');
|
now,
|
||||||
throw error;
|
now,
|
||||||
}
|
);
|
||||||
console.log(`Imported ${records.length} Attribute definitions: ${created} created, ${updated} updated, ${unchanged} unchanged.`);
|
created++;
|
||||||
console.log(`Source: ${sourcePath}`);
|
}
|
||||||
|
await recordIsacAudit({
|
||||||
|
actorId: String(actor.id),
|
||||||
|
category: 'game_data',
|
||||||
|
action: existing ? 'attribute.updated' : 'attribute.created',
|
||||||
|
entityId: id,
|
||||||
|
before,
|
||||||
|
after: await auditRecord('stat_attributes', id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await database.exec('COMMIT');
|
||||||
|
} catch (error) {
|
||||||
|
await database.exec('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`Imported ${records.length} Attribute definitions: ${created} created, ${updated} updated, ${unchanged} unchanged.`,
|
||||||
|
);
|
||||||
|
console.log(`Source: ${sourcePath}`);
|
||||||
} finally {
|
} finally {
|
||||||
await vite.close();
|
await vite.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,49 +4,83 @@ import { basename, dirname, join, resolve } from 'node:path';
|
|||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import { ISAC_DATA_TABLES, quoteIdentifier } from './isac-data-tables.mjs';
|
import { ISAC_DATA_TABLES, quoteIdentifier } from './isac-data-tables.mjs';
|
||||||
|
|
||||||
const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build-development.db');
|
const databasePath = resolve(
|
||||||
const iconDirectory = resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons');
|
process.env.TDR_DATABASE_PATH || './data/shd-build-development.db',
|
||||||
const timestamp = new Date().toISOString().replaceAll(':', '-').replace(/\.\d{3}Z$/, 'Z');
|
);
|
||||||
const outputPath = resolve(process.argv[2] || `./data/backups/isac-data-${timestamp}.json`);
|
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 database = await connect(databasePath);
|
||||||
const tables = [];
|
const tables = [];
|
||||||
let totalRows = 0;
|
let totalRows = 0;
|
||||||
|
|
||||||
for (const name of ISAC_DATA_TABLES) {
|
for (const name of ISAC_DATA_TABLES) {
|
||||||
const exists = await (await database.prepare(
|
const exists = await (
|
||||||
"SELECT 1 AS present FROM sqlite_master WHERE type='table' AND name=?"
|
await database.prepare(
|
||||||
)).get(name);
|
"SELECT 1 AS present FROM sqlite_master WHERE type='table' AND 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();
|
).get(name);
|
||||||
tables.push({ name, rowCount: rows.length, rows });
|
if (!exists)
|
||||||
totalRows += rows.length;
|
throw new Error(
|
||||||
|
`ISAC export requires missing table "${name}". Run the schema migration first.`,
|
||||||
|
);
|
||||||
|
const rows = await (
|
||||||
|
await database.prepare(`SELECT * FROM ${quoteIdentifier(name)}`)
|
||||||
|
).all();
|
||||||
|
tables.push({ name, rowCount: rows.length, rows });
|
||||||
|
totalRows += rows.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
const icons = [];
|
const icons = [];
|
||||||
const exportedStorageKeys = new Set();
|
const exportedStorageKeys = new Set();
|
||||||
const iconTable = tables.find((table) => table.name === 'game_data_icons');
|
const iconTable = tables.find((table) => table.name === 'game_data_icons');
|
||||||
const managedImageTable = tables.find((table) => table.name === 'managed_images');
|
const managedImageTable = tables.find(
|
||||||
for (const row of [...(iconTable?.rows ?? []), ...(managedImageTable?.rows ?? [])]) {
|
(table) => table.name === 'managed_images',
|
||||||
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)}".`);
|
for (const row of [
|
||||||
}
|
...(iconTable?.rows ?? []),
|
||||||
if (exportedStorageKeys.has(row.storage_key)) continue;
|
...(managedImageTable?.rows ?? []),
|
||||||
exportedStorageKeys.add(row.storage_key);
|
]) {
|
||||||
const content = await readFile(join(iconDirectory, row.storage_key));
|
if (
|
||||||
icons.push({ storageKey: row.storage_key, contentBase64: content.toString('base64') });
|
typeof row.storage_key !== 'string' ||
|
||||||
|
!/^[a-z_]+-[0-9a-f-]{36}\.webp$/i.test(row.storage_key)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid managed asset storage key "${String(row.storage_key)}".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (exportedStorageKeys.has(row.storage_key)) continue;
|
||||||
|
exportedStorageKeys.add(row.storage_key);
|
||||||
|
const content = await readFile(join(iconDirectory, row.storage_key));
|
||||||
|
icons.push({
|
||||||
|
storageKey: row.storage_key,
|
||||||
|
contentBase64: content.toString('base64'),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapshot = {
|
const snapshot = {
|
||||||
format: 'shd-build-isac-data',
|
format: 'shd-build-isac-data',
|
||||||
version: 2,
|
version: 2,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
sourceDatabase: basename(databasePath),
|
sourceDatabase: basename(databasePath),
|
||||||
totalRows,
|
totalRows,
|
||||||
tables,
|
tables,
|
||||||
icons
|
icons,
|
||||||
};
|
};
|
||||||
await mkdir(dirname(outputPath), { recursive: true });
|
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}`);
|
console.log(`ISAC snapshot: ${outputPath}`);
|
||||||
|
|||||||
@@ -5,39 +5,77 @@ import { basename, join, resolve } from 'node:path';
|
|||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import { ISAC_DATA_TABLES, quoteIdentifier } from './isac-data-tables.mjs';
|
import { ISAC_DATA_TABLES, quoteIdentifier } from './isac-data-tables.mjs';
|
||||||
|
|
||||||
const snapshotArgument = process.argv.slice(2).find((argument) => !argument.startsWith('--'));
|
const snapshotArgument = process.argv
|
||||||
if (!snapshotArgument || !process.argv.includes('--confirm-replace-isac-data')) {
|
.slice(2)
|
||||||
console.error('Usage: node scripts/isac-data-import.mjs <snapshot.json> --confirm-replace-isac-data');
|
.find((argument) => !argument.startsWith('--'));
|
||||||
process.exit(2);
|
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 snapshotPath = resolve(snapshotArgument);
|
||||||
const snapshot = JSON.parse(await readFile(snapshotPath, 'utf8'));
|
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)) {
|
if (
|
||||||
throw new Error('The selected file is not a supported SHD.build ISAC data snapshot.');
|
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);
|
const receivedNames = snapshot.tables.map((table) => table.name);
|
||||||
if (receivedNames.length !== ISAC_DATA_TABLES.length || ISAC_DATA_TABLES.some((name, index) => receivedNames[index] !== name)) {
|
if (
|
||||||
throw new Error('The ISAC snapshot table manifest is incomplete or out of order.');
|
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) {
|
for (const icon of snapshot.icons) {
|
||||||
if (typeof icon.storageKey !== 'string' || !/^[a-z_]+-[0-9a-f-]{36}\.webp$/i.test(icon.storageKey)
|
if (
|
||||||
|| typeof icon.contentBase64 !== 'string') {
|
typeof icon.storageKey !== 'string' ||
|
||||||
throw new Error('The ISAC snapshot contains an invalid uploaded icon.');
|
!/^[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 timestamp = new Date()
|
||||||
const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build-development.db');
|
.toISOString()
|
||||||
const backupPath = resolve(`./data/backups/${basename(databasePath).replace(/\.[^.]+$/, '')}-before-isac-import-${timestamp}.json`);
|
.replaceAll(':', '-')
|
||||||
const isacBackupPath = resolve(`./data/backups/isac-data-before-import-${timestamp}.json`);
|
.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 [
|
for (const [script, output] of [
|
||||||
['scripts/db-backup.mjs', backupPath],
|
['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], {
|
||||||
if (result.error) throw result.error;
|
cwd: process.cwd(),
|
||||||
if (result.status !== 0) throw new Error(`Safety backup failed in ${script}; no ISAC data was changed.`);
|
stdio: 'inherit',
|
||||||
|
env: process.env,
|
||||||
|
});
|
||||||
|
if (result.error) throw result.error;
|
||||||
|
if (result.status !== 0)
|
||||||
|
throw new Error(
|
||||||
|
`Safety backup failed in ${script}; no ISAC data was changed.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const database = await connect(databasePath);
|
const database = await connect(databasePath);
|
||||||
@@ -45,35 +83,43 @@ let importedRows = 0;
|
|||||||
await database.exec('PRAGMA foreign_keys=OFF');
|
await database.exec('PRAGMA foreign_keys=OFF');
|
||||||
await database.exec('BEGIN');
|
await database.exec('BEGIN');
|
||||||
try {
|
try {
|
||||||
for (const name of [...ISAC_DATA_TABLES].reverse()) {
|
for (const name of [...ISAC_DATA_TABLES].reverse()) {
|
||||||
await database.exec(`DELETE FROM ${quoteIdentifier(name)}`);
|
await database.exec(`DELETE FROM ${quoteIdentifier(name)}`);
|
||||||
}
|
}
|
||||||
for (const table of snapshot.tables) {
|
for (const table of snapshot.tables) {
|
||||||
for (const row of table.rows) {
|
for (const row of table.rows) {
|
||||||
const columns = Object.keys(row);
|
const columns = Object.keys(row);
|
||||||
if (!columns.length) continue;
|
if (!columns.length) continue;
|
||||||
const insert = await database.prepare(
|
const insert = await database.prepare(
|
||||||
`INSERT INTO ${quoteIdentifier(table.name)} (${columns.map(quoteIdentifier).join(', ')})
|
`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]));
|
await insert.run(...columns.map((column) => row[column]));
|
||||||
importedRows++;
|
importedRows++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await database.exec('COMMIT');
|
await database.exec('COMMIT');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await database.exec('ROLLBACK');
|
await database.exec('ROLLBACK');
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
await database.exec('PRAGMA foreign_keys=ON');
|
await database.exec('PRAGMA foreign_keys=ON');
|
||||||
}
|
}
|
||||||
|
|
||||||
const iconDirectory = resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons');
|
const iconDirectory = resolve(
|
||||||
|
process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons',
|
||||||
|
);
|
||||||
await mkdir(iconDirectory, { recursive: true });
|
await mkdir(iconDirectory, { recursive: true });
|
||||||
for (const icon of snapshot.icons) {
|
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(`Full pre-import backup: ${backupPath}`);
|
||||||
console.log(`ISAC pre-import snapshot: ${isacBackupPath}`);
|
console.log(`ISAC pre-import snapshot: ${isacBackupPath}`);
|
||||||
|
|||||||
@@ -1,76 +1,77 @@
|
|||||||
export const ISAC_DATA_TABLES = [
|
export const ISAC_DATA_TABLES = [
|
||||||
'stat_attributes',
|
'stat_attributes',
|
||||||
'talents',
|
'talents',
|
||||||
'talent_tiers',
|
'talent_tiers',
|
||||||
'conditional_effect_rules',
|
'conditional_effect_rules',
|
||||||
'conditional_effect_state_pools',
|
'conditional_effect_state_pools',
|
||||||
'conditional_effect_clauses',
|
'conditional_effect_clauses',
|
||||||
'conditional_effect_outcomes',
|
'conditional_effect_outcomes',
|
||||||
'talent_tier_effects',
|
'talent_tier_effects',
|
||||||
'talent_description_templates',
|
'talent_description_templates',
|
||||||
'talent_weapon_scopes',
|
'talent_weapon_scopes',
|
||||||
'weapon_talent_slots',
|
'weapon_talent_slots',
|
||||||
'brand_sets',
|
'brand_sets',
|
||||||
'brand_set_effects',
|
'brand_set_effects',
|
||||||
'gear',
|
'gear',
|
||||||
'gear_brand_sets',
|
'gear_brand_sets',
|
||||||
'gear_talents',
|
'gear_talents',
|
||||||
'gear_tactical_augments',
|
'gear_tactical_augments',
|
||||||
'gear_tactical_augment_costs',
|
'gear_tactical_augment_costs',
|
||||||
'weapons',
|
'weapons',
|
||||||
'core_attribute_tier_ranges',
|
'core_attribute_tier_ranges',
|
||||||
'weapon_tier_ranges',
|
'weapon_tier_ranges',
|
||||||
'weapon_talent_options',
|
'weapon_talent_options',
|
||||||
'weapon_augmentation_effects',
|
'weapon_augmentation_effects',
|
||||||
'weapon_category_attribute_bonuses',
|
'weapon_category_attribute_bonuses',
|
||||||
'os_protocols',
|
'os_protocols',
|
||||||
'os_protocol_primary_effects',
|
'os_protocol_primary_effects',
|
||||||
'os_protocol_secondary_profiles',
|
'os_protocol_secondary_profiles',
|
||||||
'os_protocol_secondary_effects',
|
'os_protocol_secondary_effects',
|
||||||
'os_protocol_talents',
|
'os_protocol_talents',
|
||||||
'os_protocol_talent_effects',
|
'os_protocol_talent_effects',
|
||||||
'skill_chip_sets',
|
'skill_chip_sets',
|
||||||
'skill_chip_two_piece_bonuses',
|
'skill_chip_two_piece_bonuses',
|
||||||
'skill_chip_primary_options',
|
'skill_chip_primary_options',
|
||||||
'skill_chip_secondary_options',
|
'skill_chip_secondary_options',
|
||||||
'skill_chip_talents',
|
'skill_chip_talents',
|
||||||
'skill_chip_talent_effects',
|
'skill_chip_talent_effects',
|
||||||
'weapon_mods',
|
'weapon_mods',
|
||||||
'weapon_mod_compatible_categories',
|
'weapon_mod_compatible_categories',
|
||||||
'weapon_mod_slots',
|
'weapon_mod_slots',
|
||||||
'weapon_mod_quality_values',
|
'weapon_mod_quality_values',
|
||||||
'weapon_mod_secondary_options',
|
'weapon_mod_secondary_options',
|
||||||
'weapon_mod_secondary_quality_values',
|
'weapon_mod_secondary_quality_values',
|
||||||
'weapon_mod_exotic_attributes',
|
'weapon_mod_exotic_attributes',
|
||||||
'weapon_mod_effects',
|
'weapon_mod_effects',
|
||||||
'weapon_mod_effect_quality_values',
|
'weapon_mod_effect_quality_values',
|
||||||
'gear_mods',
|
'gear_mods',
|
||||||
'gear_mod_quality_values',
|
'gear_mod_quality_values',
|
||||||
'gear_mod_secondary_options',
|
'gear_mod_secondary_options',
|
||||||
'gear_mod_secondary_quality_values',
|
'gear_mod_secondary_quality_values',
|
||||||
'specialization_progression_config',
|
'specialization_progression_config',
|
||||||
'specialization_rank_cost_profiles',
|
'specialization_rank_cost_profiles',
|
||||||
'upgrade_resources',
|
'upgrade_resources',
|
||||||
'specializations',
|
'specializations',
|
||||||
'specialization_combat_effects',
|
'specialization_combat_effects',
|
||||||
'specialization_focuses',
|
'specialization_focuses',
|
||||||
'specialization_abilities',
|
'specialization_abilities',
|
||||||
'specialization_ability_attacks',
|
'specialization_ability_attacks',
|
||||||
'specialization_ability_effects',
|
'specialization_ability_effects',
|
||||||
'specialization_focus_abilities',
|
'specialization_focus_abilities',
|
||||||
'specialization_tree_nodes',
|
'specialization_tree_nodes',
|
||||||
'specialization_tree_node_ranks',
|
'specialization_tree_node_ranks',
|
||||||
'specialization_tree_rank_effects',
|
'specialization_tree_rank_effects',
|
||||||
'specialization_tree_rank_costs',
|
'specialization_tree_rank_costs',
|
||||||
'specialization_tree_rank_combat_effects',
|
'specialization_tree_rank_combat_effects',
|
||||||
'specialization_tree_rank_combat_modifiers',
|
'specialization_tree_rank_combat_modifiers',
|
||||||
'calculation_revisions',
|
'calculation_revisions',
|
||||||
'calculation_stages',
|
'calculation_stages',
|
||||||
'attribute_calculation_profiles',
|
'attribute_calculation_profiles',
|
||||||
'skill_attribute_calculation_profiles',
|
'skill_attribute_calculation_profiles',
|
||||||
'calculation_calibration_cases',
|
'calculation_calibration_cases',
|
||||||
'game_data_icons',
|
'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 { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
import { basename, dirname, join, resolve } from 'node:path';
|
import { basename, dirname, join, resolve } from 'node:path';
|
||||||
|
|
||||||
const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build-development.db');
|
const databasePath = resolve(
|
||||||
const storageDirectory = resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons');
|
process.env.TDR_DATABASE_PATH || './data/shd-build-development.db',
|
||||||
const timestamp = new Date().toISOString().replaceAll(':', '-').replace(/\.\d{3}Z$/, 'Z');
|
);
|
||||||
const outputPath = resolve(process.argv[2] || `./data/backups/managed-images-${timestamp}.json`);
|
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 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 = [];
|
const images = [];
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (typeof row.storage_key !== 'string'
|
if (
|
||||||
|| !/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i.test(row.storage_key)) {
|
typeof row.storage_key !== 'string' ||
|
||||||
throw new Error(`Invalid managed-image storage key "${String(row.storage_key)}".`);
|
!/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i.test(
|
||||||
}
|
row.storage_key,
|
||||||
images.push({ row, contentBase64: (await readFile(join(storageDirectory, row.storage_key))).toString('base64') });
|
)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid managed-image storage key "${String(row.storage_key)}".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
images.push({
|
||||||
|
row,
|
||||||
|
contentBase64: (
|
||||||
|
await readFile(join(storageDirectory, row.storage_key))
|
||||||
|
).toString('base64'),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const bundle = {
|
const bundle = {
|
||||||
format: 'shd-build-managed-images',
|
format: 'shd-build-managed-images',
|
||||||
version: 1,
|
version: 1,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
sourceDatabase: basename(databasePath),
|
sourceDatabase: basename(databasePath),
|
||||||
images
|
images,
|
||||||
};
|
};
|
||||||
await mkdir(dirname(outputPath), { recursive: true });
|
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(`Exported ${images.length} managed image assets.`);
|
||||||
console.log(`Image bundle: ${outputPath}`);
|
console.log(`Image bundle: ${outputPath}`);
|
||||||
|
|||||||
@@ -4,47 +4,84 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|||||||
import { join, resolve } from 'node:path';
|
import { join, resolve } from 'node:path';
|
||||||
import { randomUUID } from 'node:crypto';
|
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')) {
|
if (!bundleArgument || !process.argv.includes('--confirm-merge')) {
|
||||||
console.error('Usage: node scripts/managed-images-import.mjs <bundle.json> --confirm-merge [--update-existing]');
|
console.error(
|
||||||
process.exit(2);
|
'Usage: node scripts/managed-images-import.mjs <bundle.json> --confirm-merge [--update-existing]',
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
}
|
}
|
||||||
const updateExisting = process.argv.includes('--update-existing');
|
const updateExisting = process.argv.includes('--update-existing');
|
||||||
const bundlePath = resolve(bundleArgument);
|
const bundlePath = resolve(bundleArgument);
|
||||||
const bundle = JSON.parse(await readFile(bundlePath, 'utf8'));
|
const bundle = JSON.parse(await readFile(bundlePath, 'utf8'));
|
||||||
if (bundle?.format !== 'shd-build-managed-images' || bundle?.version !== 1 || !Array.isArray(bundle.images)) {
|
if (
|
||||||
throw new Error('The selected file is not a supported SHD.build managed-image bundle.');
|
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) {
|
for (const image of bundle.images) {
|
||||||
if (typeof image?.row?.id !== 'string' || !/^[0-9a-f-]{36}$/i.test(image.row.id)
|
if (
|
||||||
|| typeof image.row.storage_key !== 'string'
|
typeof image?.row?.id !== '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)
|
!/^[0-9a-f-]{36}$/i.test(image.row.id) ||
|
||||||
|| typeof image.contentBase64 !== 'string') {
|
typeof image.row.storage_key !== 'string' ||
|
||||||
throw new Error('The bundle contains an invalid managed image.');
|
!/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i.test(
|
||||||
}
|
image.row.storage_key,
|
||||||
const isItemRender = ['weapon_render', 'gear_render'].includes(image.row.source_entity_type);
|
) ||
|
||||||
if (image.row.source_entity_type && (
|
typeof image.contentBase64 !== 'string'
|
||||||
typeof image.row.source_entity_id !== 'string'
|
) {
|
||||||
|| !/^[0-9a-f-]{36}$/i.test(image.row.source_entity_id)
|
throw new Error('The bundle contains an invalid managed image.');
|
||||||
|| (isItemRender
|
}
|
||||||
? !image.row.storage_key.startsWith('library-')
|
const isItemRender = ['weapon_render', 'gear_render'].includes(
|
||||||
: !image.row.storage_key.startsWith(`${image.row.source_entity_type}-`))
|
image.row.source_entity_type,
|
||||||
)) throw new Error('The bundle contains an invalid linked ISAC image.');
|
);
|
||||||
|
if (
|
||||||
|
image.row.source_entity_type &&
|
||||||
|
(typeof image.row.source_entity_id !== 'string' ||
|
||||||
|
!/^[0-9a-f-]{36}$/i.test(image.row.source_entity_id) ||
|
||||||
|
(isItemRender
|
||||||
|
? !image.row.storage_key.startsWith('library-')
|
||||||
|
: !image.row.storage_key.startsWith(
|
||||||
|
`${image.row.source_entity_type}-`,
|
||||||
|
)))
|
||||||
|
)
|
||||||
|
throw new Error('The bundle contains an invalid linked ISAC image.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const backup = spawnSync(process.execPath, ['scripts/db-backup.mjs'], {
|
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.error) throw backup.error;
|
||||||
if (backup.status !== 0) throw new Error('Safety backup failed; no managed images were imported.');
|
if (backup.status !== 0)
|
||||||
const assetBackup = spawnSync(process.execPath, ['scripts/isac-data-export.mjs'], {
|
throw new Error('Safety backup failed; no managed images were imported.');
|
||||||
cwd: process.cwd(), stdio: 'inherit', env: process.env
|
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.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 databasePath = resolve(
|
||||||
const storageDirectory = resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons');
|
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);
|
const database = await connect(databasePath);
|
||||||
await mkdir(storageDirectory, { recursive: true });
|
await mkdir(storageDirectory, { recursive: true });
|
||||||
let inserted = 0;
|
let inserted = 0;
|
||||||
@@ -53,64 +90,140 @@ let unchanged = 0;
|
|||||||
|
|
||||||
const preflight = [];
|
const preflight = [];
|
||||||
for (const image of bundle.images) {
|
for (const image of bundle.images) {
|
||||||
const row = image.row;
|
const row = image.row;
|
||||||
const existing = await (await database.prepare('SELECT * FROM managed_images WHERE id=?')).get(row.id);
|
const existing = await (
|
||||||
if (existing && !updateExisting) {
|
await database.prepare('SELECT * FROM managed_images WHERE id=?')
|
||||||
const same = ['name','category','tags_json','is_icon','force_white','storage_key','byte_size','width','height','source_entity_type','source_entity_id']
|
).get(row.id);
|
||||||
.every((key) => String(existing[key]) === String(row[key]));
|
if (existing && !updateExisting) {
|
||||||
if (!same) throw new Error(`Image ${row.id} already exists with different metadata. Re-run with --update-existing after review.`);
|
const same = [
|
||||||
unchanged++;
|
'name',
|
||||||
continue;
|
'category',
|
||||||
}
|
'tags_json',
|
||||||
const creator = await (await database.prepare('SELECT id FROM users WHERE id=?')).get(row.created_by);
|
'is_icon',
|
||||||
if (!creator) throw new Error(`Image "${row.name}" references a creator that does not exist in the target database.`);
|
'force_white',
|
||||||
const nameConflict = await (await database.prepare('SELECT id FROM managed_images WHERE name=? COLLATE NOCASE AND id<>?')).get(row.name, row.id);
|
'storage_key',
|
||||||
if (nameConflict) throw new Error(`Image name "${row.name}" is already assigned to a different target asset.`);
|
'byte_size',
|
||||||
if (row.source_entity_type && row.source_entity_id) {
|
'width',
|
||||||
const sourceConflict = await (await database.prepare(
|
'height',
|
||||||
'SELECT id FROM managed_images WHERE source_entity_type=? AND source_entity_id=? AND id<>?'
|
'source_entity_type',
|
||||||
)).get(row.source_entity_type, row.source_entity_id, row.id);
|
'source_entity_id',
|
||||||
if (sourceConflict) throw new Error(`The ${row.source_entity_type} image ${row.source_entity_id} is already registered under a different library asset.`);
|
].every((key) => String(existing[key]) === String(row[key]));
|
||||||
}
|
if (!same)
|
||||||
preflight.push({ image, existing });
|
throw new Error(
|
||||||
|
`Image ${row.id} already exists with different metadata. Re-run with --update-existing after review.`,
|
||||||
|
);
|
||||||
|
unchanged++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const creator = await (
|
||||||
|
await database.prepare('SELECT id FROM users WHERE id=?')
|
||||||
|
).get(row.created_by);
|
||||||
|
if (!creator)
|
||||||
|
throw new Error(
|
||||||
|
`Image "${row.name}" references a creator that does not exist in the target database.`,
|
||||||
|
);
|
||||||
|
const nameConflict = await (
|
||||||
|
await database.prepare(
|
||||||
|
'SELECT id FROM managed_images WHERE name=? COLLATE NOCASE AND id<>?',
|
||||||
|
)
|
||||||
|
).get(row.name, row.id);
|
||||||
|
if (nameConflict)
|
||||||
|
throw new Error(
|
||||||
|
`Image name "${row.name}" is already assigned to a different target asset.`,
|
||||||
|
);
|
||||||
|
if (row.source_entity_type && row.source_entity_id) {
|
||||||
|
const sourceConflict = await (
|
||||||
|
await database.prepare(
|
||||||
|
'SELECT id FROM managed_images WHERE source_entity_type=? AND source_entity_id=? AND id<>?',
|
||||||
|
)
|
||||||
|
).get(row.source_entity_type, row.source_entity_id, row.id);
|
||||||
|
if (sourceConflict)
|
||||||
|
throw new Error(
|
||||||
|
`The ${row.source_entity_type} image ${row.source_entity_id} is already registered under a different library asset.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
preflight.push({ image, existing });
|
||||||
}
|
}
|
||||||
|
|
||||||
await database.exec('BEGIN');
|
await database.exec('BEGIN');
|
||||||
try {
|
try {
|
||||||
for (const { image, existing } of preflight) {
|
for (const { image, existing } of preflight) {
|
||||||
const row = image.row;
|
const row = image.row;
|
||||||
await writeFile(join(storageDirectory, row.storage_key), Buffer.from(image.contentBase64, 'base64'), {
|
await writeFile(
|
||||||
mode: 0o600, flag: existing || row.source_entity_type ? 'w' : 'wx'
|
join(storageDirectory, row.storage_key),
|
||||||
});
|
Buffer.from(image.contentBase64, 'base64'),
|
||||||
if (existing) {
|
{
|
||||||
await (await database.prepare(`UPDATE managed_images SET
|
mode: 0o600,
|
||||||
|
flag: existing || row.source_entity_type ? 'w' : 'wx',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
await (
|
||||||
|
await database.prepare(`UPDATE managed_images SET
|
||||||
name=?,category=?,tags_json=?,is_icon=?,force_white=?,storage_key=?,original_filename=?,
|
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(
|
byte_size=?,width=?,height=?,source_entity_type=?,source_entity_id=?,updated_at=? WHERE id=?`)
|
||||||
row.name,row.category,row.tags_json,row.is_icon,row.force_white,row.storage_key,row.original_filename,
|
).run(
|
||||||
row.byte_size,row.width,row.height,row.source_entity_type ?? null,row.source_entity_id ?? null,row.updated_at,row.id
|
row.name,
|
||||||
);
|
row.category,
|
||||||
updated++;
|
row.tags_json,
|
||||||
} else {
|
row.is_icon,
|
||||||
await (await database.prepare(`INSERT INTO managed_images
|
row.force_white,
|
||||||
|
row.storage_key,
|
||||||
|
row.original_filename,
|
||||||
|
row.byte_size,
|
||||||
|
row.width,
|
||||||
|
row.height,
|
||||||
|
row.source_entity_type ?? null,
|
||||||
|
row.source_entity_id ?? null,
|
||||||
|
row.updated_at,
|
||||||
|
row.id,
|
||||||
|
);
|
||||||
|
updated++;
|
||||||
|
} else {
|
||||||
|
await (
|
||||||
|
await database.prepare(`INSERT INTO managed_images
|
||||||
(id,name,category,tags_json,is_icon,force_white,storage_key,original_filename,byte_size,width,height,
|
(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)
|
source_entity_type,source_entity_id,created_by,created_at,updated_at)
|
||||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
||||||
row.id,row.name,row.category,row.tags_json,row.is_icon,row.force_white,row.storage_key,row.original_filename,
|
).run(
|
||||||
row.byte_size,row.width,row.height,row.source_entity_type ?? null,row.source_entity_id ?? null,
|
row.id,
|
||||||
row.created_by,row.created_at,row.updated_at
|
row.name,
|
||||||
);
|
row.category,
|
||||||
inserted++;
|
row.tags_json,
|
||||||
}
|
row.is_icon,
|
||||||
await (await database.prepare(`INSERT INTO audit_log
|
row.force_white,
|
||||||
(id,actor_id,entity_type,entity_id,action,before_json,after_json,created_at) VALUES(?,?,?,?,?,?,?,?)`)).run(
|
row.storage_key,
|
||||||
randomUUID(), row.created_by, 'system_configuration', row.id,
|
row.original_filename,
|
||||||
existing ? 'image.import_updated' : 'image.imported',
|
row.byte_size,
|
||||||
existing ? JSON.stringify(existing) : null, JSON.stringify(row), new Date().toISOString()
|
row.width,
|
||||||
);
|
row.height,
|
||||||
}
|
row.source_entity_type ?? null,
|
||||||
await database.exec('COMMIT');
|
row.source_entity_id ?? null,
|
||||||
|
row.created_by,
|
||||||
|
row.created_at,
|
||||||
|
row.updated_at,
|
||||||
|
);
|
||||||
|
inserted++;
|
||||||
|
}
|
||||||
|
await (
|
||||||
|
await database.prepare(`INSERT INTO audit_log
|
||||||
|
(id,actor_id,entity_type,entity_id,action,before_json,after_json,created_at) VALUES(?,?,?,?,?,?,?,?)`)
|
||||||
|
).run(
|
||||||
|
randomUUID(),
|
||||||
|
row.created_by,
|
||||||
|
'system_configuration',
|
||||||
|
row.id,
|
||||||
|
existing ? 'image.import_updated' : 'image.imported',
|
||||||
|
existing ? JSON.stringify(existing) : null,
|
||||||
|
JSON.stringify(row),
|
||||||
|
new Date().toISOString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await database.exec('COMMIT');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await database.exec('ROLLBACK');
|
await database.exec('ROLLBACK');
|
||||||
throw error;
|
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 nodemailer from 'nodemailer';
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
if (!process.env.SMTP_HOST || !process.env.SMTP_FROM) throw new Error('SMTP delivery is not configured.');
|
if (!process.env.SMTP_HOST || !process.env.SMTP_FROM)
|
||||||
const database = await connect(resolve(process.env.TDR_DATABASE_PATH || './data/shd-build.db'));
|
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({
|
const transport = nodemailer.createTransport({
|
||||||
host: process.env.SMTP_HOST,
|
host: process.env.SMTP_HOST,
|
||||||
port: Number(process.env.SMTP_PORT || 587),
|
port: Number(process.env.SMTP_PORT || 587),
|
||||||
secure: process.env.SMTP_SECURE === 'true',
|
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
|
||||||
requireTLS: process.env.SMTP_SECURE !== 'true',
|
? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD }
|
||||||
disableFileAccess: true,
|
: undefined,
|
||||||
disableUrlAccess: true
|
requireTLS: process.env.SMTP_SECURE !== 'true',
|
||||||
|
disableFileAccess: true,
|
||||||
|
disableUrlAccess: true,
|
||||||
});
|
});
|
||||||
const origin = (process.env.APP_ORIGIN || 'http://localhost:5173').replace(/\/+$/, '');
|
const origin = (process.env.APP_ORIGIN || 'http://localhost:5173').replace(
|
||||||
const recipients = await (await database.prepare(`SELECT DISTINCT n.user_id,e.email
|
/\/+$/,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
const recipients = await (
|
||||||
|
await database.prepare(`SELECT DISTINCT n.user_id,e.email
|
||||||
FROM notifications n
|
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 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
|
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;
|
let sent = 0;
|
||||||
for (const recipient of recipients) {
|
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'
|
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`)
|
||||||
if (!items.length) continue;
|
).all(recipient.user_id);
|
||||||
const escape = (value) => String(value).replace(/[&<>"']/g, (character) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character]);
|
if (!items.length) continue;
|
||||||
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) =>
|
||||||
await transport.sendMail({
|
String(value).replace(
|
||||||
from: process.env.SMTP_FROM,
|
/[&<>"']/g,
|
||||||
to: recipient.email,
|
(character) =>
|
||||||
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>`
|
'<': '<',
|
||||||
});
|
'>': '>',
|
||||||
const now = new Date().toISOString();
|
'"': '"',
|
||||||
for (const item of items) await (await database.prepare('UPDATE notifications SET emailed_at=? WHERE id=? AND emailed_at IS NULL')).run(now, item.id);
|
"'": ''',
|
||||||
sent += 1;
|
})[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>`,
|
||||||
|
});
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
for (const item of items)
|
||||||
|
await (
|
||||||
|
await database.prepare(
|
||||||
|
'UPDATE notifications SET emailed_at=? WHERE id=? AND emailed_at IS NULL',
|
||||||
|
)
|
||||||
|
).run(now, item.id);
|
||||||
|
sent += 1;
|
||||||
}
|
}
|
||||||
console.log(`Sent ${sent} daily notification digest${sent === 1 ? '' : 's'}.`);
|
console.log(`Sent ${sent} daily notification digest${sent === 1 ? '' : 's'}.`);
|
||||||
|
|||||||
@@ -3,67 +3,100 @@ import nodemailer from 'nodemailer';
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { resolve } from 'node:path';
|
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 now = new Date().toISOString();
|
||||||
const due = await (await database.prepare(`SELECT * FROM oversight_notification_queue
|
const due = await (
|
||||||
WHERE delivered_at IS NULL AND deliver_after<=? ORDER BY user_id,oversight_category,created_at`)).all(now);
|
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();
|
const groups = new Map();
|
||||||
for (const item of due) {
|
for (const item of due) {
|
||||||
const key = `${item.user_id}:${item.oversight_category}:${item.frequency}:${item.deliver_after}`;
|
const key = `${item.user_id}:${item.oversight_category}:${item.frequency}:${item.deliver_after}`;
|
||||||
if (!groups.has(key)) groups.set(key, []);
|
if (!groups.has(key)) groups.set(key, []);
|
||||||
groups.get(key).push(item);
|
groups.get(key).push(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
const smtpConfigured = Boolean(process.env.SMTP_HOST && process.env.SMTP_FROM);
|
const smtpConfigured = Boolean(process.env.SMTP_HOST && process.env.SMTP_FROM);
|
||||||
const transport = smtpConfigured ? nodemailer.createTransport({
|
const transport = smtpConfigured
|
||||||
host: process.env.SMTP_HOST,
|
? nodemailer.createTransport({
|
||||||
port: Number(process.env.SMTP_PORT || 587),
|
host: process.env.SMTP_HOST,
|
||||||
secure: process.env.SMTP_SECURE === 'true',
|
port: Number(process.env.SMTP_PORT || 587),
|
||||||
auth: process.env.SMTP_USER ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD } : undefined,
|
secure: process.env.SMTP_SECURE === 'true',
|
||||||
requireTLS: process.env.SMTP_SECURE !== 'true',
|
auth: process.env.SMTP_USER
|
||||||
disableFileAccess: true,
|
? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD }
|
||||||
disableUrlAccess: true
|
: undefined,
|
||||||
}) : null;
|
requireTLS: process.env.SMTP_SECURE !== 'true',
|
||||||
const origin = (process.env.APP_ORIGIN || 'http://localhost:5173').replace(/\/+$/, '');
|
disableFileAccess: true,
|
||||||
|
disableUrlAccess: true,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
const origin = (process.env.APP_ORIGIN || 'http://localhost:5173').replace(
|
||||||
|
/\/+$/,
|
||||||
|
'',
|
||||||
|
);
|
||||||
let delivered = 0;
|
let delivered = 0;
|
||||||
|
|
||||||
for (const items of groups.values()) {
|
for (const items of groups.values()) {
|
||||||
const first = items[0];
|
const first = items[0];
|
||||||
const categoryLabel = first.oversight_category === 'avatars' ? 'avatar' : 'data change';
|
const categoryLabel =
|
||||||
const title = `${items.length} ${categoryLabel} review${items.length === 1 ? '' : 's'} awaiting Oversight`;
|
first.oversight_category === 'avatars' ? 'avatar' : 'data change';
|
||||||
const details = items.slice(0, 10).map((item) => `• ${item.summary}`).join('\n');
|
const title = `${items.length} ${categoryLabel} review${items.length === 1 ? '' : 's'} awaiting Oversight`;
|
||||||
const remainder = items.length > 10 ? `\n• ${items.length - 10} additional review items` : '';
|
const details = items
|
||||||
const body = `${details}${remainder}`;
|
.slice(0, 10)
|
||||||
const notificationId = randomUUID();
|
.map((item) => `• ${item.summary}`)
|
||||||
await (await database.prepare(`INSERT INTO notifications
|
.join('\n');
|
||||||
|
const remainder =
|
||||||
|
items.length > 10 ? `\n• ${items.length - 10} additional review items` : '';
|
||||||
|
const body = `${details}${remainder}`;
|
||||||
|
const notificationId = randomUUID();
|
||||||
|
await (
|
||||||
|
await database.prepare(`INSERT INTO notifications
|
||||||
(id,user_id,category,source,title,body,link,mandatory,created_at)
|
(id,user_id,category,source,title,body,link,mandatory,created_at)
|
||||||
VALUES(?,?,'isac','ISAC OVERSIGHT NETWORK',?,?,?,1,?)`)).run(
|
VALUES(?,?,'isac','ISAC OVERSIGHT NETWORK',?,?,?,1,?)`)
|
||||||
notificationId, first.user_id, title, body, first.link, now
|
).run(notificationId, first.user_id, title, body, first.link, now);
|
||||||
);
|
for (const item of items) {
|
||||||
for (const item of items) {
|
await (
|
||||||
await (await database.prepare('UPDATE oversight_notification_queue SET delivered_at=? WHERE id=? AND delivered_at IS NULL')).run(now, item.id);
|
await database.prepare(
|
||||||
}
|
'UPDATE oversight_notification_queue SET delivered_at=? WHERE id=? AND delivered_at IS NULL',
|
||||||
const preference = await (await database.prepare(
|
)
|
||||||
"SELECT email_frequency FROM notification_preferences WHERE user_id=? AND category='isac'"
|
).run(now, item.id);
|
||||||
)).get(first.user_id);
|
}
|
||||||
if (transport && preference?.email_frequency === 'immediate') {
|
const preference = await (
|
||||||
const identity = await (await database.prepare('SELECT email FROM user_emails WHERE user_id=?')).get(first.user_id);
|
await database.prepare(
|
||||||
if (typeof identity?.email === 'string') {
|
"SELECT email_frequency FROM notification_preferences WHERE user_id=? AND category='isac'",
|
||||||
try {
|
)
|
||||||
await transport.sendMail({
|
).get(first.user_id);
|
||||||
from: process.env.SMTP_FROM,
|
if (transport && preference?.email_frequency === 'immediate') {
|
||||||
to: identity.email,
|
const identity = await (
|
||||||
subject: `SHD.build · ${title}`,
|
await database.prepare('SELECT email FROM user_emails WHERE user_id=?')
|
||||||
text: `${body}\n\n${origin}${first.link}`,
|
).get(first.user_id);
|
||||||
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>`
|
if (typeof identity?.email === 'string') {
|
||||||
});
|
try {
|
||||||
await (await database.prepare('UPDATE notifications SET emailed_at=? WHERE id=?')).run(now, notificationId);
|
await transport.sendMail({
|
||||||
} catch (error) {
|
from: process.env.SMTP_FROM,
|
||||||
console.error(`Unable to email Oversight digest ${notificationId}`, error);
|
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>`,
|
||||||
delivered += items.length;
|
});
|
||||||
|
await (
|
||||||
|
await database.prepare(
|
||||||
|
'UPDATE notifications SET emailed_at=? WHERE id=?',
|
||||||
|
)
|
||||||
|
).run(now, notificationId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Unable to email Oversight digest ${notificationId}`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delivered += items.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Delivered ${delivered} queued Oversight review alert${delivered === 1 ? '' : 's'} in ${groups.size} digest${groups.size === 1 ? '' : 's'}.`);
|
console.log(
|
||||||
|
`Delivered ${delivered} queued Oversight review alert${delivered === 1 ? '' : 's'} in ${groups.size} digest${groups.size === 1 ? '' : 's'}.`,
|
||||||
|
);
|
||||||
|
|||||||
@@ -6,35 +6,47 @@ import sharp from 'sharp';
|
|||||||
|
|
||||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
const outputDirectory = path.join(root, 'static', 'brand');
|
const outputDirectory = path.join(root, 'static', 'brand');
|
||||||
const fontDirectory = path.join(root, 'node_modules', '@fontsource', 'barlow', 'files');
|
const fontDirectory = path.join(
|
||||||
const heavy = opentype.parse(fs.readFileSync(path.join(fontDirectory, 'barlow-latin-900-normal.woff')).buffer);
|
root,
|
||||||
const medium = opentype.parse(fs.readFileSync(path.join(fontDirectory, 'barlow-latin-700-normal.woff')).buffer);
|
'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 });
|
fs.mkdirSync(outputDirectory, { recursive: true });
|
||||||
|
|
||||||
const colors = {
|
const colors = {
|
||||||
background: '#0a0c0d',
|
background: '#0a0c0d',
|
||||||
surface: '#111415',
|
surface: '#111415',
|
||||||
border: '#303536',
|
border: '#303536',
|
||||||
grid: '#2a3031',
|
grid: '#2a3031',
|
||||||
orange: '#f6a821',
|
orange: '#f6a821',
|
||||||
white: '#ecebe5'
|
white: '#ecebe5',
|
||||||
};
|
};
|
||||||
|
|
||||||
function trackedPath(font, value, x, baseline, size, tracking = 0) {
|
function trackedPath(font, value, x, baseline, size, tracking = 0) {
|
||||||
let cursor = x;
|
let cursor = x;
|
||||||
let data = '';
|
let data = '';
|
||||||
for (const character of value) {
|
for (const character of value) {
|
||||||
const glyph = font.charToGlyph(character);
|
const glyph = font.charToGlyph(character);
|
||||||
data += glyph.getPath(cursor, baseline, size).toPathData(2);
|
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 };
|
return { data, width: cursor - x - tracking };
|
||||||
}
|
}
|
||||||
|
|
||||||
function emblem(x, y, size) {
|
function emblem(x, y, size) {
|
||||||
const scale = size / 100;
|
const scale = size / 100;
|
||||||
return `
|
return `
|
||||||
<g transform="translate(${x} ${y}) scale(${scale})">
|
<g transform="translate(${x} ${y}) scale(${scale})">
|
||||||
<g fill="none" stroke="${colors.orange}" stroke-width="12" stroke-linecap="butt">
|
<g fill="none" stroke="${colors.orange}" stroke-width="12" stroke-linecap="butt">
|
||||||
<path d="M73 20A38 38 0 0 0 15 63"/>
|
<path d="M73 20A38 38 0 0 0 15 63"/>
|
||||||
@@ -47,10 +59,10 @@ function emblem(x, y, size) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function writePng(svg, filename, width, height) {
|
async function writePng(svg, filename, width, height) {
|
||||||
await sharp(Buffer.from(svg))
|
await sharp(Buffer.from(svg))
|
||||||
.resize(width, height, { fit: 'fill' })
|
.resize(width, height, { fit: 'fill' })
|
||||||
.png({ compressionLevel: 9, adaptiveFiltering: true })
|
.png({ compressionLevel: 9, adaptiveFiltering: true })
|
||||||
.toFile(path.join(outputDirectory, filename));
|
.toFile(path.join(outputDirectory, filename));
|
||||||
}
|
}
|
||||||
|
|
||||||
const transparentLogo = `
|
const transparentLogo = `
|
||||||
@@ -154,10 +166,17 @@ const inviteSplash = `
|
|||||||
await writePng(transparentLogo, 'shd-core-segments.png', 1024, 1024);
|
await writePng(transparentLogo, 'shd-core-segments.png', 1024, 1024);
|
||||||
await writePng(serverIcon, 'shd-build-discord-icon.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(banner, 'shd-build-discord-banner-1920x1080.png', 1920, 1080);
|
||||||
await writePng(inviteSplash, 'shd-build-discord-invite-background-1920x1080.png', 1920, 1080);
|
await writePng(
|
||||||
await sharp(path.join(outputDirectory, 'shd-build-discord-banner-1920x1080.png'))
|
inviteSplash,
|
||||||
.resize(960, 540)
|
'shd-build-discord-invite-background-1920x1080.png',
|
||||||
.png({ compressionLevel: 9, adaptiveFiltering: true })
|
1920,
|
||||||
.toFile(path.join(outputDirectory, 'shd-build-discord-banner-960x540.png'));
|
1080,
|
||||||
|
);
|
||||||
|
await sharp(
|
||||||
|
path.join(outputDirectory, 'shd-build-discord-banner-1920x1080.png'),
|
||||||
|
)
|
||||||
|
.resize(960, 540)
|
||||||
|
.png({ compressionLevel: 9, adaptiveFiltering: true })
|
||||||
|
.toFile(path.join(outputDirectory, 'shd-build-discord-banner-960x540.png'));
|
||||||
|
|
||||||
console.log(`Rendered brand assets to ${outputDirectory}`);
|
console.log(`Rendered brand assets to ${outputDirectory}`);
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
const endpoint = process.env.SCHEDULER_URL;
|
const endpoint = process.env.SCHEDULER_URL;
|
||||||
const token = process.env.SCHEDULER_TOKEN;
|
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) {
|
while (true) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(endpoint, {
|
const response = await fetch(endpoint, {
|
||||||
method: 'POST',
|
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)
|
||||||
console.log(`Scheduler tick: ${JSON.stringify(await response.json())}`);
|
throw new Error(`Scheduler endpoint returned HTTP ${response.status}.`);
|
||||||
} catch (error) {
|
console.log(`Scheduler tick: ${JSON.stringify(await response.json())}`);
|
||||||
console.error('Scheduler tick failed', error);
|
} catch (error) {
|
||||||
}
|
console.error('Scheduler tick failed', error);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 60_000));
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 60_000));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import { resolve } from 'node:path';
|
|||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import { initializeDefaultUsers } from './default-users.mjs';
|
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 database = await connect(databasePath);
|
||||||
const resetPasswords = process.argv.includes('--reset-passwords');
|
const resetPasswords = process.argv.includes('--reset-passwords');
|
||||||
|
|
||||||
|
|||||||
@@ -3,22 +3,63 @@ import { randomUUID } from 'node:crypto';
|
|||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
const targetName = process.argv[2] || 'Aewyn';
|
const targetName = process.argv[2] || 'Aewyn';
|
||||||
const database = await connect(resolve(process.env.TDR_DATABASE_PATH || './data/shd-build.db'));
|
const database = await connect(
|
||||||
const user = await (await database.prepare(`SELECT users.id,COALESCE(user_profiles.display_name,users.username) AS display_name
|
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
|
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
|
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}.`);
|
if (!user) throw new Error(`No user matched ${targetName}.`);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const samples = [
|
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],
|
'isac',
|
||||||
['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],
|
'ISAC AVATAR OVERSIGHT',
|
||||||
['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]
|
'[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()) {
|
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)
|
(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,51 +4,74 @@ import { dirname, join, resolve } from 'node:path';
|
|||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
|
|
||||||
const outputArgument = process.argv[2];
|
const outputArgument = process.argv[2];
|
||||||
const timestamp = new Date().toISOString().replaceAll(':', '-').replace(/\.\d{3}Z$/, 'Z');
|
const timestamp = new Date()
|
||||||
const outputPath = resolve(outputArgument || `./data/backups/shd-stage-bootstrap-${timestamp}.json`);
|
.toISOString()
|
||||||
|
.replaceAll(':', '-')
|
||||||
|
.replace(/\.\d{3}Z$/, 'Z');
|
||||||
|
const outputPath = resolve(
|
||||||
|
outputArgument || `./data/backups/shd-stage-bootstrap-${timestamp}.json`,
|
||||||
|
);
|
||||||
const temporaryBackupPath = `${outputPath}.database.json`;
|
const temporaryBackupPath = `${outputPath}.database.json`;
|
||||||
const databaseBackup = spawnSync(process.execPath, ['scripts/db-backup.mjs', temporaryBackupPath], {
|
const databaseBackup = spawnSync(
|
||||||
cwd: process.cwd(),
|
process.execPath,
|
||||||
stdio: 'inherit',
|
['scripts/db-backup.mjs', temporaryBackupPath],
|
||||||
env: process.env
|
{
|
||||||
});
|
cwd: process.cwd(),
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: process.env,
|
||||||
|
},
|
||||||
|
);
|
||||||
if (databaseBackup.error) throw databaseBackup.error;
|
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) {
|
async function collectFiles(directory, pattern) {
|
||||||
try {
|
try {
|
||||||
const names = (await readdir(directory)).filter((name) => pattern.test(name)).sort();
|
const names = (await readdir(directory))
|
||||||
return await Promise.all(names.map(async (name) => ({
|
.filter((name) => pattern.test(name))
|
||||||
name,
|
.sort();
|
||||||
contentBase64: (await readFile(join(directory, name))).toString('base64')
|
return await Promise.all(
|
||||||
})));
|
names.map(async (name) => ({
|
||||||
} catch (error) {
|
name,
|
||||||
if (error?.code === 'ENOENT') return [];
|
contentBase64: (await readFile(join(directory, name))).toString(
|
||||||
throw error;
|
'base64',
|
||||||
}
|
),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code === 'ENOENT') return [];
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const database = JSON.parse(await readFile(temporaryBackupPath, 'utf8'));
|
const database = JSON.parse(await readFile(temporaryBackupPath, 'utf8'));
|
||||||
const avatars = await collectFiles(
|
const avatars = await collectFiles(
|
||||||
resolve(process.env.TDR_AVATAR_STORAGE_PATH || './data/avatars'),
|
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(
|
const gameIcons = await collectFiles(
|
||||||
resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons'),
|
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 = {
|
const bundle = {
|
||||||
format: 'shd-build-stage-bootstrap',
|
format: 'shd-build-stage-bootstrap',
|
||||||
version: 1,
|
version: 1,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
database,
|
database,
|
||||||
files: { avatars, gameIcons }
|
files: { avatars, gameIcons },
|
||||||
};
|
};
|
||||||
await mkdir(dirname(outputPath), { recursive: true });
|
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`, {
|
||||||
console.log(`Staging bootstrap bundle includes ${avatars.length} avatars and ${gameIcons.length} game-data icons.`);
|
encoding: 'utf8',
|
||||||
console.log(`Bootstrap bundle: ${outputPath}`);
|
flag: 'wx',
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`Staging bootstrap bundle includes ${avatars.length} avatars and ${gameIcons.length} game-data icons.`,
|
||||||
|
);
|
||||||
|
console.log(`Bootstrap bundle: ${outputPath}`);
|
||||||
} finally {
|
} finally {
|
||||||
await rm(temporaryBackupPath, { force: true });
|
await rm(temporaryBackupPath, { force: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,106 +4,165 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|||||||
import { join, resolve } from 'node:path';
|
import { join, resolve } from 'node:path';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
|
|
||||||
const bundleArgument = process.argv.slice(2).find((argument) => !argument.startsWith('--'));
|
const bundleArgument = process.argv
|
||||||
if (!bundleArgument || !process.argv.includes('--confirm-empty-staging-replace')
|
.slice(2)
|
||||||
|| process.env.ALLOW_STAGING_BOOTSTRAP !== 'true') {
|
.find((argument) => !argument.startsWith('--'));
|
||||||
console.error('Staging bootstrap requires ALLOW_STAGING_BOOTSTRAP=true and:');
|
if (
|
||||||
console.error('node scripts/stage-bootstrap-import.mjs <bundle.json> --confirm-empty-staging-replace');
|
!bundleArgument ||
|
||||||
process.exit(2);
|
!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',
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
const bundlePath = resolve(bundleArgument);
|
const bundlePath = resolve(bundleArgument);
|
||||||
const bundle = JSON.parse(await readFile(bundlePath, 'utf8'));
|
const bundle = JSON.parse(await readFile(bundlePath, 'utf8'));
|
||||||
if (bundle?.format !== 'shd-build-stage-bootstrap' || bundle?.version !== 1
|
if (
|
||||||
|| bundle.database?.format !== 'shd-build-database-backup' || bundle.database?.version !== 1
|
bundle?.format !== 'shd-build-stage-bootstrap' ||
|
||||||
|| !Array.isArray(bundle.database.tables) || !Array.isArray(bundle.files?.avatars)
|
bundle?.version !== 1 ||
|
||||||
|| !Array.isArray(bundle.files?.gameIcons)) {
|
bundle.database?.format !== 'shd-build-database-backup' ||
|
||||||
throw new Error('The selected file is not a supported SHD.build staging bootstrap bundle.');
|
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) {
|
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 (
|
||||||
throw new Error('The bootstrap bundle contains an invalid avatar file.');
|
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) {
|
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') {
|
if (
|
||||||
throw new Error('The bootstrap bundle contains an invalid game-data icon file.');
|
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'], {
|
const safetyBackup = spawnSync(process.execPath, ['scripts/db-backup.mjs'], {
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
env: process.env
|
env: process.env,
|
||||||
});
|
});
|
||||||
if (safetyBackup.error) throw safetyBackup.error;
|
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);
|
const database = await connect(databasePath);
|
||||||
for (const tableName of ['users', 'builds', 'audit_log', 'data_change_proposals']) {
|
for (const tableName of [
|
||||||
const row = await (await database.prepare(`SELECT COUNT(*) AS count FROM "${tableName}"`)).get();
|
'users',
|
||||||
if (Number(row?.count ?? 0) > 0) {
|
'builds',
|
||||||
throw new Error(`Staging bootstrap refused: target table "${tableName}" is not empty.`);
|
'audit_log',
|
||||||
}
|
'data_change_proposals',
|
||||||
|
]) {
|
||||||
|
const row = await (
|
||||||
|
await database.prepare(`SELECT COUNT(*) AS count FROM "${tableName}"`)
|
||||||
|
).get();
|
||||||
|
if (Number(row?.count ?? 0) > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Staging bootstrap refused: target table "${tableName}" is not empty.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetTables = await (await database.prepare(`
|
const targetTables = await (
|
||||||
|
await database.prepare(`
|
||||||
SELECT name FROM sqlite_master
|
SELECT name FROM sqlite_master
|
||||||
WHERE type='table' AND name NOT LIKE 'sqlite_%'
|
WHERE type='table' AND name NOT LIKE 'sqlite_%'
|
||||||
ORDER BY rowid
|
ORDER BY rowid
|
||||||
`)).all();
|
`)
|
||||||
|
).all();
|
||||||
const targetNames = new Set(targetTables.map((row) => String(row.name)));
|
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) {
|
for (const row of targetTables) {
|
||||||
if (!sourceNames.has(String(row.name))) {
|
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
|
const sourceOnlyTables = bundle.database.tables
|
||||||
.map((table) => String(table.name))
|
.map((table) => String(table.name))
|
||||||
.filter((name) => !targetNames.has(name));
|
.filter((name) => !targetNames.has(name));
|
||||||
if (sourceOnlyTables.length) {
|
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;
|
let restoredRows = 0;
|
||||||
await database.exec('PRAGMA foreign_keys=OFF');
|
await database.exec('PRAGMA foreign_keys=OFF');
|
||||||
await database.exec('BEGIN');
|
await database.exec('BEGIN');
|
||||||
try {
|
try {
|
||||||
for (const row of [...targetTables].reverse()) {
|
for (const row of [...targetTables].reverse()) {
|
||||||
await database.exec(`DELETE FROM ${quoteIdentifier(row.name)}`);
|
await database.exec(`DELETE FROM ${quoteIdentifier(row.name)}`);
|
||||||
}
|
}
|
||||||
for (const table of bundle.database.tables) {
|
for (const table of bundle.database.tables) {
|
||||||
for (const row of table.rows) {
|
for (const row of table.rows) {
|
||||||
const columns = Object.keys(row);
|
const columns = Object.keys(row);
|
||||||
if (!columns.length) continue;
|
if (!columns.length) continue;
|
||||||
const insert = await database.prepare(
|
const insert = await database.prepare(
|
||||||
`INSERT INTO ${quoteIdentifier(table.name)} (${columns.map(quoteIdentifier).join(', ')})
|
`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]));
|
await insert.run(...columns.map((column) => row[column]));
|
||||||
restoredRows++;
|
restoredRows++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await database.exec('COMMIT');
|
await database.exec('COMMIT');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await database.exec('ROLLBACK');
|
await database.exec('ROLLBACK');
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
await database.exec('PRAGMA foreign_keys=ON');
|
await database.exec('PRAGMA foreign_keys=ON');
|
||||||
}
|
}
|
||||||
|
|
||||||
const avatarDirectory = resolve(process.env.TDR_AVATAR_STORAGE_PATH || './data/avatars');
|
const avatarDirectory = resolve(
|
||||||
const gameIconDirectory = resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons');
|
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(avatarDirectory, { recursive: true });
|
||||||
await mkdir(gameIconDirectory, { recursive: true });
|
await mkdir(gameIconDirectory, { recursive: true });
|
||||||
for (const file of bundle.files.avatars) {
|
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) {
|
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}`);
|
console.log(`Source bundle: ${bundlePath}`);
|
||||||
|
|||||||
@@ -6,41 +6,49 @@ import process from 'node:process';
|
|||||||
const stagingFile = resolve('.env.staging');
|
const stagingFile = resolve('.env.staging');
|
||||||
const dotenvxCli = resolve('node_modules/@dotenvx/dotenvx/src/cli/dotenvx.js');
|
const dotenvxCli = resolve('node_modules/@dotenvx/dotenvx/src/cli/dotenvx.js');
|
||||||
const sharedKeys = [
|
const sharedKeys = [
|
||||||
'DISCORD_CLIENT_ID',
|
'DISCORD_CLIENT_ID',
|
||||||
'DISCORD_CLIENT_SECRET',
|
'DISCORD_CLIENT_SECRET',
|
||||||
'SMTP_HOST',
|
'SMTP_HOST',
|
||||||
'SMTP_PORT',
|
'SMTP_PORT',
|
||||||
'SMTP_SECURE',
|
'SMTP_SECURE',
|
||||||
'SMTP_USER',
|
'SMTP_USER',
|
||||||
'SMTP_PASSWORD',
|
'SMTP_PASSWORD',
|
||||||
'SMTP_FROM'
|
'SMTP_FROM',
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const key of sharedKeys) {
|
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) {
|
function dotenvx(action) {
|
||||||
const result = spawnSync(process.execPath, [dotenvxCli, action, '-f', stagingFile], {
|
const result = spawnSync(
|
||||||
cwd: process.cwd(),
|
process.execPath,
|
||||||
stdio: 'inherit'
|
[dotenvxCli, action, '-f', stagingFile],
|
||||||
});
|
{
|
||||||
if (result.error) throw result.error;
|
cwd: process.cwd(),
|
||||||
if (result.status !== 0) throw new Error(`dotenvx ${action} failed.`);
|
stdio: 'inherit',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (result.error) throw result.error;
|
||||||
|
if (result.status !== 0) throw new Error(`dotenvx ${action} failed.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
dotenvx('decrypt');
|
dotenvx('decrypt');
|
||||||
try {
|
try {
|
||||||
let contents = await readFile(stagingFile, 'utf8');
|
let contents = await readFile(stagingFile, 'utf8');
|
||||||
for (const key of sharedKeys) {
|
for (const key of sharedKeys) {
|
||||||
const replacement = `${key}=${JSON.stringify(process.env[key])}`;
|
const replacement = `${key}=${JSON.stringify(process.env[key])}`;
|
||||||
const pattern = new RegExp(`^${key}=.*$`, 'm');
|
const pattern = new RegExp(`^${key}=.*$`, 'm');
|
||||||
if (!pattern.test(contents)) throw new Error(`Staging environment is missing ${key}.`);
|
if (!pattern.test(contents))
|
||||||
contents = contents.replace(pattern, replacement);
|
throw new Error(`Staging environment is missing ${key}.`);
|
||||||
}
|
contents = contents.replace(pattern, replacement);
|
||||||
await writeFile(stagingFile, contents, 'utf8');
|
}
|
||||||
|
await writeFile(stagingFile, contents, 'utf8');
|
||||||
} finally {
|
} finally {
|
||||||
dotenvx('encrypt');
|
dotenvx('encrypt');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Copied shared Discord and SMTP credentials into encrypted staging configuration.');
|
console.log(
|
||||||
|
'Copied shared Discord and SMTP credentials into encrypted staging configuration.',
|
||||||
|
);
|
||||||
|
|||||||
@@ -3,64 +3,102 @@ import nodemailer from 'nodemailer';
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { resolve } from 'node:path';
|
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 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
|
FROM automated_system_message_deliveries delivery
|
||||||
JOIN automated_system_messages message ON message.id=delivery.message_id
|
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
|
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 smtpConfigured = Boolean(process.env.SMTP_HOST && process.env.SMTP_FROM);
|
||||||
const transport = smtpConfigured ? nodemailer.createTransport({
|
const transport = smtpConfigured
|
||||||
host: process.env.SMTP_HOST,
|
? nodemailer.createTransport({
|
||||||
port: Number(process.env.SMTP_PORT || 587),
|
host: process.env.SMTP_HOST,
|
||||||
secure: process.env.SMTP_SECURE === 'true',
|
port: Number(process.env.SMTP_PORT || 587),
|
||||||
auth: process.env.SMTP_USER ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD } : undefined,
|
secure: process.env.SMTP_SECURE === 'true',
|
||||||
requireTLS: process.env.SMTP_SECURE !== 'true',
|
auth: process.env.SMTP_USER
|
||||||
disableFileAccess: true,
|
? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD }
|
||||||
disableUrlAccess: true
|
: undefined,
|
||||||
}) : null;
|
requireTLS: process.env.SMTP_SECURE !== 'true',
|
||||||
const origin = (process.env.APP_ORIGIN || 'http://localhost:5173').replace(/\/+$/, '');
|
disableFileAccess: true,
|
||||||
const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (character) => ({
|
disableUrlAccess: true,
|
||||||
'&': '&',
|
})
|
||||||
'<': '<',
|
: null;
|
||||||
'>': '>',
|
const origin = (process.env.APP_ORIGIN || 'http://localhost:5173').replace(
|
||||||
'"': '"',
|
/\/+$/,
|
||||||
"'": '''
|
'',
|
||||||
})[character]);
|
);
|
||||||
|
const escapeHtml = (value) =>
|
||||||
|
String(value).replace(
|
||||||
|
/[&<>"']/g,
|
||||||
|
(character) =>
|
||||||
|
({
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": ''',
|
||||||
|
})[character],
|
||||||
|
);
|
||||||
let delivered = 0;
|
let delivered = 0;
|
||||||
|
|
||||||
for (const message of due) {
|
for (const message of due) {
|
||||||
const notificationId = randomUUID();
|
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)
|
(id,user_id,category,source,title,body,link,actions_json,mandatory,created_at)
|
||||||
VALUES(?,?,'isac',?,?,?,?,?,1,?)`)).run(
|
VALUES(?,?,'isac',?,?,?,?,?,1,?)`)
|
||||||
notificationId, message.user_id, message.source, message.title, message.body,
|
).run(
|
||||||
message.link ?? null, message.actions_json ?? null, now
|
notificationId,
|
||||||
);
|
message.user_id,
|
||||||
await (await database.prepare(`UPDATE automated_system_message_deliveries
|
message.source,
|
||||||
SET delivered_at=?,notification_id=? WHERE id=? AND delivered_at IS NULL`))
|
message.title,
|
||||||
.run(now, notificationId, message.delivery_id);
|
message.body,
|
||||||
const preference = await (await database.prepare(
|
message.link ?? null,
|
||||||
"SELECT email_frequency FROM notification_preferences WHERE user_id=? AND category='isac'"
|
message.actions_json ?? null,
|
||||||
)).get(message.user_id);
|
now,
|
||||||
if (transport && preference?.email_frequency === 'immediate') {
|
);
|
||||||
const identity = await (await database.prepare('SELECT email FROM user_emails WHERE user_id=?')).get(message.user_id);
|
await (
|
||||||
if (typeof identity?.email === 'string') {
|
await database.prepare(`UPDATE automated_system_message_deliveries
|
||||||
try {
|
SET delivered_at=?,notification_id=? WHERE id=? AND delivered_at IS NULL`)
|
||||||
await transport.sendMail({
|
).run(now, notificationId, message.delivery_id);
|
||||||
from: process.env.SMTP_FROM,
|
const preference = await (
|
||||||
to: identity.email,
|
await database.prepare(
|
||||||
subject: `SHD.build · ${message.title}`,
|
"SELECT email_frequency FROM notification_preferences WHERE user_id=? AND category='isac'",
|
||||||
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>`
|
).get(message.user_id);
|
||||||
});
|
if (transport && preference?.email_frequency === 'immediate') {
|
||||||
await (await database.prepare('UPDATE notifications SET emailed_at=? WHERE id=?')).run(now, notificationId);
|
const identity = await (
|
||||||
} catch (error) {
|
await database.prepare('SELECT email FROM user_emails WHERE user_id=?')
|
||||||
console.error(`Unable to email automated notification ${notificationId}`, error);
|
).get(message.user_id);
|
||||||
}
|
if (typeof identity?.email === 'string') {
|
||||||
}
|
try {
|
||||||
}
|
await transport.sendMail({
|
||||||
delivered += 1;
|
from: process.env.SMTP_FROM,
|
||||||
|
to: identity.email,
|
||||||
|
subject: `SHD.build · ${message.title}`,
|
||||||
|
text: `${message.body}\n\n${message.link ? `${origin}${message.link}` : origin}`,
|
||||||
|
html: `<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);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Unable to email automated notification ${notificationId}`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delivered += 1;
|
||||||
}
|
}
|
||||||
console.log(`Delivered ${delivered} automated ISAC system notification${delivered === 1 ? '' : 's'}.`);
|
console.log(
|
||||||
|
`Delivered ${delivered} automated ISAC system notification${delivered === 1 ? '' : 's'}.`,
|
||||||
|
);
|
||||||
|
|||||||
@@ -4,37 +4,41 @@ import { join, resolve } from 'node:path';
|
|||||||
|
|
||||||
const iconDirectory = resolve('./static');
|
const iconDirectory = resolve('./static');
|
||||||
const iconFiles = (await readdir(iconDirectory))
|
const iconFiles = (await readdir(iconDirectory))
|
||||||
.filter((file) => file.toLocaleLowerCase().endsWith('.png'))
|
.filter((file) => file.toLocaleLowerCase().endsWith('.png'))
|
||||||
.sort((left, right) => left.localeCompare(right));
|
.sort((left, right) => left.localeCompare(right));
|
||||||
|
|
||||||
for (const file of iconFiles) {
|
for (const file of iconFiles) {
|
||||||
const source = join(iconDirectory, file);
|
const source = join(iconDirectory, file);
|
||||||
const temporary = join(iconDirectory, `.${file}.trimmed`);
|
const temporary = join(iconDirectory, `.${file}.trimmed`);
|
||||||
const before = await sharp(source).metadata();
|
const before = await sharp(source).metadata();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await sharp(source)
|
const result = await sharp(source)
|
||||||
.trim({ background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
.trim({ background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
||||||
.extend({
|
.extend({
|
||||||
top: 2,
|
top: 2,
|
||||||
right: 2,
|
right: 2,
|
||||||
bottom: 2,
|
bottom: 2,
|
||||||
left: 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 })
|
.png({ compressionLevel: 9, adaptiveFiltering: true })
|
||||||
.toFile(temporary);
|
.toFile(temporary);
|
||||||
|
|
||||||
if (!result.width || !result.height || result.channels !== 4) {
|
if (!result.width || !result.height || result.channels !== 4) {
|
||||||
throw new Error(`${file} did not produce a valid transparent RGBA icon.`);
|
throw new Error(`${file} did not produce a valid transparent RGBA icon.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await rename(temporary, source);
|
await rename(temporary, source);
|
||||||
console.log(`${file}: ${before.width}x${before.height} -> ${result.width}x${result.height}`);
|
console.log(
|
||||||
} catch (error) {
|
`${file}: ${before.width}x${before.height} -> ${result.width}x${result.height}`,
|
||||||
await unlink(temporary).catch(() => {});
|
);
|
||||||
throw error;
|
} 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.`,
|
||||||
|
);
|
||||||
|
|||||||
+18172
-2070
File diff suppressed because one or more lines are too long
Vendored
+10
-10
@@ -1,16 +1,16 @@
|
|||||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||||
// for information about these interfaces
|
// for information about these interfaces
|
||||||
declare global {
|
declare global {
|
||||||
namespace App {
|
namespace App {
|
||||||
interface Error {
|
interface Error {
|
||||||
message: string;
|
message: string;
|
||||||
errorId?: string;
|
errorId?: string;
|
||||||
}
|
}
|
||||||
// interface Locals {}
|
// interface Locals {}
|
||||||
// interface PageData {}
|
// interface PageData {}
|
||||||
// interface PageState {}
|
// interface PageState {}
|
||||||
// interface Platform {}
|
// interface Platform {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
+9
-9
@@ -1,12 +1,12 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="text-scale" content="scale" />
|
<meta name="text-scale" content="scale" />
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
<div style="display: contents">%sveltekit.body%</div>
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+137
-9
@@ -1,12 +1,140 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
<title>%sveltekit.status% · SHD.build system fault</title>
|
<title>%sveltekit.status% · SHD.build system fault</title>
|
||||||
<style>
|
<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 {
|
||||||
</style>
|
color-scheme: dark;
|
||||||
</head>
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
<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>
|
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>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+143
-81
@@ -2,89 +2,151 @@ import { json, type Handle, type HandleServerError } from '@sveltejs/kit';
|
|||||||
import { stringify } from 'devalue';
|
import { stringify } from 'devalue';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { getSessionUser } from '$lib/server/auth/session';
|
import { getSessionUser } from '$lib/server/auth/session';
|
||||||
import { getSiteAccessBlock, getSiteAccessSettings } from '$lib/server/site-access';
|
import {
|
||||||
import { DATA_MODULES, proposalActionResult, queueDataProposal, shouldModerateDataMutation, type DataModule } from '$lib/server/data-moderation';
|
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 }) => {
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
if (event.url.pathname === '/admin' || event.url.pathname.startsWith('/admin/')) {
|
if (
|
||||||
const location = `/isac${event.url.pathname.slice('/admin'.length)}${event.url.search}`;
|
event.url.pathname === '/admin' ||
|
||||||
return new Response(null, { status: 308, headers: { location } });
|
event.url.pathname.startsWith('/admin/')
|
||||||
}
|
) {
|
||||||
const siteSettings = await getSiteAccessSettings();
|
const location = `/isac${event.url.pathname.slice('/admin'.length)}${event.url.search}`;
|
||||||
const sessionUser = await getSessionUser(event.cookies);
|
return new Response(null, { status: 308, headers: { location } });
|
||||||
const accessBlock = getSiteAccessBlock(siteSettings, event.url.pathname, Boolean(sessionUser));
|
}
|
||||||
if (accessBlock) {
|
const siteSettings = await getSiteAccessSettings();
|
||||||
const target = new URL('/coming-soon', event.url);
|
const sessionUser = await getSessionUser(event.cookies);
|
||||||
if (accessBlock.kind === 'section') target.searchParams.set('unavailable', accessBlock.section);
|
const accessBlock = getSiteAccessBlock(
|
||||||
if (event.request.method === 'GET' || event.request.method === 'HEAD') {
|
siteSettings,
|
||||||
return new Response(null, { status: 303, headers: { location: `${target.pathname}${target.search}` } });
|
event.url.pathname,
|
||||||
}
|
Boolean(sessionUser),
|
||||||
return json(
|
);
|
||||||
{
|
if (accessBlock) {
|
||||||
error: accessBlock.kind === 'section'
|
const target = new URL('/coming-soon', event.url);
|
||||||
? 'This section is not available.'
|
if (accessBlock.kind === 'section')
|
||||||
: 'The public site is not available yet.'
|
target.searchParams.set('unavailable', accessBlock.section);
|
||||||
},
|
if (event.request.method === 'GET' || event.request.method === 'HEAD') {
|
||||||
{ status: 404 }
|
return new Response(null, {
|
||||||
);
|
status: 303,
|
||||||
}
|
headers: { location: `${target.pathname}${target.search}` },
|
||||||
if (event.request.method === 'POST') {
|
});
|
||||||
const match = event.url.pathname.match(/^\/isac\/([^/]+)$/);
|
}
|
||||||
const module = match?.[1] as DataModule | undefined;
|
return json(
|
||||||
const action = event.url.search.startsWith('?/') ? event.url.search.slice(2) : '';
|
{
|
||||||
if (module && DATA_MODULES.includes(module) && action) {
|
error:
|
||||||
// Publishing calculation rules is never a contributor proposal. The
|
accessBlock.kind === 'section'
|
||||||
// action has its own dedicated permission and confirmation contract.
|
? 'This section is not available.'
|
||||||
if (module === 'calculations' && action === 'publishRevision') return resolve(event);
|
: 'The public site is not available yet.',
|
||||||
const user = sessionUser;
|
},
|
||||||
const access = await shouldModerateDataMutation(user);
|
{ status: 404 },
|
||||||
if (!access.canManage && access.canPropose && user) {
|
);
|
||||||
if (!access.contributorEnabled) {
|
}
|
||||||
if (event.request.headers.get('accept')?.includes('application/json')) {
|
if (event.request.method === 'POST') {
|
||||||
return json({ type: 'failure', status: 403, data: { contributorModeRequired: true } }, { status: 403 });
|
const match = event.url.pathname.match(/^\/isac\/([^/]+)$/);
|
||||||
}
|
const module = match?.[1] as DataModule | undefined;
|
||||||
return new Response('Contributor mode must be enabled before submitting data changes.', { status: 403 });
|
const action = event.url.search.startsWith('?/')
|
||||||
}
|
? event.url.search.slice(2)
|
||||||
const submittedForm = await event.request.formData();
|
: '';
|
||||||
const proposal = await queueDataProposal(user, module, action, submittedForm);
|
if (module && DATA_MODULES.includes(module) && action) {
|
||||||
if (event.request.headers.get('accept')?.includes('application/json')) {
|
// Publishing calculation rules is never a contributor proposal. The
|
||||||
const result = proposalActionResult(module, action, Boolean(proposal.entityId), proposal.id);
|
// action has its own dedicated permission and confirmation contract.
|
||||||
if (module === 'attributes' && action === 'save') {
|
if (module === 'calculations' && action === 'publishRevision')
|
||||||
Object.assign(result, {
|
return resolve(event);
|
||||||
savedItem: {
|
const user = sessionUser;
|
||||||
id: proposal.entityId ?? proposal.draftEntityId,
|
const access = await shouldModerateDataMutation(user);
|
||||||
name: String(submittedForm.get('name') ?? ''),
|
if (!access.canManage && access.canPropose && user) {
|
||||||
type: String(submittedForm.get('type') ?? ''),
|
if (!access.contributorEnabled) {
|
||||||
category: String(submittedForm.get('category') ?? ''),
|
if (
|
||||||
calculation_type: String(submittedForm.get('calculationType') ?? ''),
|
event.request.headers.get('accept')?.includes('application/json')
|
||||||
unit: String(submittedForm.get('unit') ?? ''),
|
) {
|
||||||
active: 1,
|
return json(
|
||||||
pending: true,
|
{
|
||||||
proposalId: proposal.id,
|
type: 'failure',
|
||||||
proposerName: user.displayName
|
status: 403,
|
||||||
}
|
data: { contributorModeRequired: true },
|
||||||
});
|
},
|
||||||
}
|
{ status: 403 },
|
||||||
return json({
|
);
|
||||||
type: 'success',
|
}
|
||||||
status: 200,
|
return new Response(
|
||||||
data: stringify(result)
|
'Contributor mode must be enabled before submitting data changes.',
|
||||||
});
|
{ status: 403 },
|
||||||
}
|
);
|
||||||
const target = new URL(event.url.pathname, event.url);
|
}
|
||||||
target.searchParams.set('proposal', proposal.id);
|
const submittedForm = await event.request.formData();
|
||||||
return new Response(null, { status: 303, headers: { location: `${target.pathname}${target.search}` } });
|
const proposal = await queueDataProposal(
|
||||||
}
|
user,
|
||||||
}
|
module,
|
||||||
}
|
action,
|
||||||
return resolve(event);
|
submittedForm,
|
||||||
|
);
|
||||||
|
if (event.request.headers.get('accept')?.includes('application/json')) {
|
||||||
|
const result = proposalActionResult(
|
||||||
|
module,
|
||||||
|
action,
|
||||||
|
Boolean(proposal.entityId),
|
||||||
|
proposal.id,
|
||||||
|
);
|
||||||
|
if (module === 'attributes' && action === 'save') {
|
||||||
|
Object.assign(result, {
|
||||||
|
savedItem: {
|
||||||
|
id: proposal.entityId ?? proposal.draftEntityId,
|
||||||
|
name: String(submittedForm.get('name') ?? ''),
|
||||||
|
type: String(submittedForm.get('type') ?? ''),
|
||||||
|
category: String(submittedForm.get('category') ?? ''),
|
||||||
|
calculation_type: String(
|
||||||
|
submittedForm.get('calculationType') ?? '',
|
||||||
|
),
|
||||||
|
unit: String(submittedForm.get('unit') ?? ''),
|
||||||
|
active: 1,
|
||||||
|
pending: true,
|
||||||
|
proposalId: proposal.id,
|
||||||
|
proposerName: user.displayName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return json({
|
||||||
|
type: 'success',
|
||||||
|
status: 200,
|
||||||
|
data: stringify(result),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const target = new URL(event.url.pathname, event.url);
|
||||||
|
target.searchParams.set('proposal', proposal.id);
|
||||||
|
return new Response(null, {
|
||||||
|
status: 303,
|
||||||
|
headers: { location: `${target.pathname}${target.search}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resolve(event);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleError: HandleServerError = ({ error, event, status, message }) => {
|
export const handleError: HandleServerError = ({
|
||||||
const errorId = randomUUID().slice(0, 8).toUpperCase();
|
error,
|
||||||
console.error(`[${errorId}] ${event.request.method} ${event.url.pathname} -> ${status}`, error);
|
event,
|
||||||
return {
|
status,
|
||||||
message: status >= 500 ? 'An unexpected fault interrupted this operation. No further action was applied.' : message,
|
message,
|
||||||
errorId
|
}) => {
|
||||||
};
|
const errorId = randomUUID().slice(0, 8).toUpperCase();
|
||||||
|
console.error(
|
||||||
|
`[${errorId}] ${event.request.method} ${event.url.pathname} -> ${status}`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
message:
|
||||||
|
status >= 500
|
||||||
|
? 'An unexpected fault interrupted this operation. No further action was applied.'
|
||||||
|
: message,
|
||||||
|
errorId,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
+49
-34
@@ -1,50 +1,65 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
agentProfileHref,
|
agentProfileHref,
|
||||||
displayNameKey,
|
displayNameKey,
|
||||||
newAgentDisplayName,
|
newAgentDisplayName,
|
||||||
publicProfileSlugBase
|
publicProfileSlugBase,
|
||||||
} from './agents.js';
|
} from './agents.js';
|
||||||
|
|
||||||
describe('display names', () => {
|
describe('display names', () => {
|
||||||
it('uses one normalized key for uniqueness checks', () => {
|
it('uses one normalized key for uniqueness checks', () => {
|
||||||
expect(displayNameKey('Aewyn')).toBe(displayNameKey('Aewyn'));
|
expect(displayNameKey('Aewyn')).toBe(displayNameKey('Aewyn'));
|
||||||
expect(displayNameKey('E\u0301lite')).toBe(displayNameKey('Élite'));
|
expect(displayNameKey('E\u0301lite')).toBe(displayNameKey('Élite'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates readable short-UID placeholders', () => {
|
it('creates readable short-UID placeholders', () => {
|
||||||
expect(newAgentDisplayName(47)).toBe('Agent 47');
|
expect(newAgentDisplayName(47)).toBe('Agent 47');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('publicProfileSlugBase', () => {
|
describe('publicProfileSlugBase', () => {
|
||||||
it('creates readable normalized slugs', () => {
|
it('creates readable normalized slugs', () => {
|
||||||
expect(publicProfileSlugBase(' Aewyn Prime ', '12345678-0000-4000-8000-000000000000'))
|
expect(
|
||||||
.toBe('aewyn-prime');
|
publicProfileSlugBase(
|
||||||
expect(publicProfileSlugBase('Élite 装備', '12345678-0000-4000-8000-000000000000'))
|
' Aewyn Prime ',
|
||||||
.toBe('élite-装備');
|
'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', () => {
|
it('uses a stable fallback when a name has no letters or numbers', () => {
|
||||||
expect(publicProfileSlugBase('⚡️', '12345678-0000-4000-8000-000000000000'))
|
expect(
|
||||||
.toBe('agent-12345678');
|
publicProfileSlugBase('⚡️', '12345678-0000-4000-8000-000000000000'),
|
||||||
});
|
).toBe('agent-12345678');
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps UUID-shaped display names out of the legacy UUID namespace', () => {
|
it('keeps UUID-shaped display names out of the legacy UUID namespace', () => {
|
||||||
expect(publicProfileSlugBase(
|
expect(
|
||||||
'57a1160e-632f-4695-927f-7df03e20753f',
|
publicProfileSlugBase(
|
||||||
'12345678-0000-4000-8000-000000000000'
|
'57a1160e-632f-4695-927f-7df03e20753f',
|
||||||
)).toBe('agent-57a1160e-632f-4695-927f-7df03e20753f');
|
'12345678-0000-4000-8000-000000000000',
|
||||||
});
|
),
|
||||||
|
).toBe('agent-57a1160e-632f-4695-927f-7df03e20753f');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('agentProfileHref', () => {
|
describe('agentProfileHref', () => {
|
||||||
it('prefers the public slug and safely encodes it', () => {
|
it('prefers the public slug and safely encodes it', () => {
|
||||||
expect(agentProfileHref({ id: 'uuid', publicSlug: 'élite 装備' }, 'profile-comments'))
|
expect(
|
||||||
.toBe('/agents/%C3%A9lite%20%E8%A3%85%E5%82%99#profile-comments');
|
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', () => {
|
it('falls back to the UUID for unmigrated records', () => {
|
||||||
expect(agentProfileHref({ id: 'uuid' })).toBe('/agents/uuid');
|
expect(agentProfileHref({ id: 'uuid' })).toBe('/agents/uuid');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+25
-21
@@ -1,36 +1,40 @@
|
|||||||
export type AgentProfileReference = {
|
export type AgentProfileReference = {
|
||||||
id: string;
|
id: string;
|
||||||
publicSlug?: string | null;
|
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 {
|
export function displayNameKey(displayName: string): string {
|
||||||
return displayName.normalize('NFKC').toLocaleLowerCase('en-US');
|
return displayName.normalize('NFKC').toLocaleLowerCase('en-US');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function newAgentDisplayName(sequence: number): string {
|
export function newAgentDisplayName(sequence: number): string {
|
||||||
return `Agent ${Math.max(1, Math.trunc(sequence))}`;
|
return `Agent ${Math.max(1, Math.trunc(sequence))}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function publicProfileSlugBase(displayName: string, userId: string): string {
|
export function publicProfileSlugBase(
|
||||||
const normalized = displayName
|
displayName: string,
|
||||||
.normalize('NFKC')
|
userId: string,
|
||||||
.toLocaleLowerCase('en-US')
|
): string {
|
||||||
.replace(/[^\p{L}\p{N}\p{M}]+/gu, '-')
|
const normalized = displayName
|
||||||
.replace(/^-+|-+$/g, '')
|
.normalize('NFKC')
|
||||||
.slice(0, 64)
|
.toLocaleLowerCase('en-US')
|
||||||
.replace(/-+$/g, '');
|
.replace(/[^\p{L}\p{N}\p{M}]+/gu, '-')
|
||||||
const fallback = `agent-${userId.replaceAll('-', '').slice(0, 8).toLowerCase()}`;
|
.replace(/^-+|-+$/g, '')
|
||||||
if (!/[\p{L}\p{N}]/u.test(normalized)) return fallback;
|
.slice(0, 64)
|
||||||
return UUID_PATTERN.test(normalized) ? `agent-${normalized}` : normalized;
|
.replace(/-+$/g, '');
|
||||||
|
const fallback = `agent-${userId.replaceAll('-', '').slice(0, 8).toLowerCase()}`;
|
||||||
|
if (!/[\p{L}\p{N}]/u.test(normalized)) return fallback;
|
||||||
|
return UUID_PATTERN.test(normalized) ? `agent-${normalized}` : normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function agentProfileHref(
|
export function agentProfileHref(
|
||||||
agent: AgentProfileReference,
|
agent: AgentProfileReference,
|
||||||
fragment = ''
|
fragment = '',
|
||||||
): string {
|
): string {
|
||||||
const identifier = agent.publicSlug || agent.id;
|
const identifier = agent.publicSlug || agent.id;
|
||||||
const suffix = fragment ? `#${fragment.replace(/^#/, '')}` : '';
|
const suffix = fragment ? `#${fragment.replace(/^#/, '')}` : '';
|
||||||
return `/agents/${encodeURIComponent(identifier)}${suffix}`;
|
return `/agents/${encodeURIComponent(identifier)}${suffix}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,120 +1,120 @@
|
|||||||
import type {
|
import type {
|
||||||
AiMechanicQuestion,
|
AiMechanicQuestion,
|
||||||
AiMechanicResponseMeta
|
AiMechanicResponseMeta,
|
||||||
} from './mechanic-authoring';
|
} from './mechanic-authoring';
|
||||||
|
|
||||||
export const AI_CALCULATION_STAGE_OPERATORS = [
|
export const AI_CALCULATION_STAGE_OPERATORS = [
|
||||||
'base_damage',
|
'base_damage',
|
||||||
'flat_add',
|
'flat_add',
|
||||||
'additive_percent',
|
'additive_percent',
|
||||||
'multiplicative_percent',
|
'multiplicative_percent',
|
||||||
'expected_critical',
|
'expected_critical',
|
||||||
'expected_headshot',
|
'expected_headshot',
|
||||||
'rate_of_fire',
|
'rate_of_fire',
|
||||||
'magazine_size',
|
'magazine_size',
|
||||||
'reload_speed',
|
'reload_speed',
|
||||||
'firing_cycle'
|
'firing_cycle',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const AI_SKILL_CALCULATION_STAGES = [
|
export const AI_SKILL_CALCULATION_STAGES = [
|
||||||
'engineering',
|
'engineering',
|
||||||
'flat_damage',
|
'flat_damage',
|
||||||
'skill_damage',
|
'skill_damage',
|
||||||
'final_damage',
|
'final_damage',
|
||||||
'critical_chance',
|
'critical_chance',
|
||||||
'critical_damage',
|
'critical_damage',
|
||||||
'multishot_chance',
|
'multishot_chance',
|
||||||
'multishot_damage',
|
'multishot_damage',
|
||||||
'cooldown_recovery',
|
'cooldown_recovery',
|
||||||
'duration'
|
'duration',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type AiCalculationStageDraft = {
|
export type AiCalculationStageDraft = {
|
||||||
stageKey: string;
|
stageKey: string;
|
||||||
label: string;
|
label: string;
|
||||||
operator: (typeof AI_CALCULATION_STAGE_OPERATORS)[number];
|
operator: (typeof AI_CALCULATION_STAGE_OPERATORS)[number];
|
||||||
displayOrder: number;
|
displayOrder: number;
|
||||||
lockedPosition: 'first' | 'last' | null;
|
lockedPosition: 'first' | 'last' | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AiCalculationProfileDraft = {
|
export type AiCalculationProfileDraft = {
|
||||||
statAttributeId: string;
|
statAttributeId: string;
|
||||||
stageKey: string;
|
stageKey: string;
|
||||||
weight: number;
|
weight: number;
|
||||||
softCap: number | null;
|
softCap: number | null;
|
||||||
hardCap: number | null;
|
hardCap: number | null;
|
||||||
curveType: 'none' | 'power' | 'smoothstep';
|
curveType: 'none' | 'power' | 'smoothstep';
|
||||||
curveParameter: number | null;
|
curveParameter: number | null;
|
||||||
weaponCategory: string;
|
weaponCategory: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AiSkillCalculationProfileDraft = {
|
export type AiSkillCalculationProfileDraft = {
|
||||||
statAttributeId: string;
|
statAttributeId: string;
|
||||||
stageKey: (typeof AI_SKILL_CALCULATION_STAGES)[number];
|
stageKey: (typeof AI_SKILL_CALCULATION_STAGES)[number];
|
||||||
operator: 'add' | 'multiply';
|
operator: 'add' | 'multiply';
|
||||||
weight: number;
|
weight: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AiCalculationRevisionDraft = {
|
export type AiCalculationRevisionDraft = {
|
||||||
name: string;
|
name: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
stages: AiCalculationStageDraft[];
|
stages: AiCalculationStageDraft[];
|
||||||
profiles: AiCalculationProfileDraft[];
|
profiles: AiCalculationProfileDraft[];
|
||||||
skillProfiles: AiSkillCalculationProfileDraft[];
|
skillProfiles: AiSkillCalculationProfileDraft[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AiCalculationTranscriptEntry = {
|
export type AiCalculationTranscriptEntry = {
|
||||||
questionId: string;
|
questionId: string;
|
||||||
question: string;
|
question: string;
|
||||||
answer: string;
|
answer: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AiCalculationAuthoringRequest = {
|
export type AiCalculationAuthoringRequest = {
|
||||||
instructions: string;
|
instructions: string;
|
||||||
baseRevisionId: string | null;
|
baseRevisionId: string | null;
|
||||||
currentDraft: AiCalculationRevisionDraft;
|
currentDraft: AiCalculationRevisionDraft;
|
||||||
transcript: AiCalculationTranscriptEntry[];
|
transcript: AiCalculationTranscriptEntry[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AiCalculationQuestion = AiMechanicQuestion;
|
export type AiCalculationQuestion = AiMechanicQuestion;
|
||||||
|
|
||||||
export type AiCalculationCapabilityGap = {
|
export type AiCalculationCapabilityGap = {
|
||||||
area: 'stage_operator' | 'curve' | 'skill_stage' | 'attribute' | 'runtime';
|
area: 'stage_operator' | 'curve' | 'skill_stage' | 'attribute' | 'runtime';
|
||||||
requiredBehavior: string;
|
requiredBehavior: string;
|
||||||
whyUnsupported: string;
|
whyUnsupported: string;
|
||||||
developerChange: string;
|
developerChange: string;
|
||||||
workaround: string | null;
|
workaround: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AiCalculationClarificationResponse = {
|
export type AiCalculationClarificationResponse = {
|
||||||
status: 'clarification_required';
|
status: 'clarification_required';
|
||||||
summary: string;
|
summary: string;
|
||||||
assumptions: string[];
|
assumptions: string[];
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
questions: AiCalculationQuestion[];
|
questions: AiCalculationQuestion[];
|
||||||
meta: AiMechanicResponseMeta;
|
meta: AiMechanicResponseMeta;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AiCalculationDraftResponse = {
|
export type AiCalculationDraftResponse = {
|
||||||
status: 'draft_ready';
|
status: 'draft_ready';
|
||||||
draft: AiCalculationRevisionDraft;
|
draft: AiCalculationRevisionDraft;
|
||||||
summary: string;
|
summary: string;
|
||||||
assumptions: string[];
|
assumptions: string[];
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
pseudoScript: string;
|
pseudoScript: string;
|
||||||
meta: AiMechanicResponseMeta;
|
meta: AiMechanicResponseMeta;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AiCalculationCapabilityGapResponse = {
|
export type AiCalculationCapabilityGapResponse = {
|
||||||
status: 'capability_gap';
|
status: 'capability_gap';
|
||||||
summary: string;
|
summary: string;
|
||||||
assumptions: string[];
|
assumptions: string[];
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
gaps: AiCalculationCapabilityGap[];
|
gaps: AiCalculationCapabilityGap[];
|
||||||
meta: AiMechanicResponseMeta;
|
meta: AiMechanicResponseMeta;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AiCalculationAuthoringResponse =
|
export type AiCalculationAuthoringResponse =
|
||||||
| AiCalculationClarificationResponse
|
| AiCalculationClarificationResponse
|
||||||
| AiCalculationDraftResponse
|
| AiCalculationDraftResponse
|
||||||
| AiCalculationCapabilityGapResponse;
|
| AiCalculationCapabilityGapResponse;
|
||||||
|
|||||||
@@ -1,140 +1,143 @@
|
|||||||
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
|
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
|
||||||
|
|
||||||
export const AI_MECHANIC_SOURCE_KINDS = [
|
export const AI_MECHANIC_SOURCE_KINDS = [
|
||||||
'talent',
|
'talent',
|
||||||
'exotic_weapon_talent',
|
'exotic_weapon_talent',
|
||||||
'os_protocol_talent',
|
'os_protocol_talent',
|
||||||
'skill_chip_talent',
|
'skill_chip_talent',
|
||||||
'specialization_tree_rank'
|
'specialization_tree_rank',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type AiMechanicSourceKind = (typeof AI_MECHANIC_SOURCE_KINDS)[number];
|
export type AiMechanicSourceKind = (typeof AI_MECHANIC_SOURCE_KINDS)[number];
|
||||||
|
|
||||||
export const AI_MECHANIC_SOURCE_PROFILES = {
|
export const AI_MECHANIC_SOURCE_PROFILES = {
|
||||||
talent: {
|
talent: {
|
||||||
variantKeys: ['I', 'II'],
|
variantKeys: ['I', 'II'],
|
||||||
allowAssignedAbility: false,
|
allowAssignedAbility: false,
|
||||||
allowProtocolUpgradeScaling: false
|
allowProtocolUpgradeScaling: false,
|
||||||
},
|
},
|
||||||
exotic_weapon_talent: {
|
exotic_weapon_talent: {
|
||||||
variantKeys: ['I', 'II'],
|
variantKeys: ['I', 'II'],
|
||||||
allowAssignedAbility: false,
|
allowAssignedAbility: false,
|
||||||
allowProtocolUpgradeScaling: false
|
allowProtocolUpgradeScaling: false,
|
||||||
},
|
},
|
||||||
os_protocol_talent: {
|
os_protocol_talent: {
|
||||||
variantKeys: ['base'],
|
variantKeys: ['base'],
|
||||||
allowAssignedAbility: false,
|
allowAssignedAbility: false,
|
||||||
allowProtocolUpgradeScaling: true
|
allowProtocolUpgradeScaling: true,
|
||||||
},
|
},
|
||||||
skill_chip_talent: {
|
skill_chip_talent: {
|
||||||
variantKeys: ['minimum', 'maximum'],
|
variantKeys: ['minimum', 'maximum'],
|
||||||
allowAssignedAbility: true,
|
allowAssignedAbility: true,
|
||||||
allowProtocolUpgradeScaling: false
|
allowProtocolUpgradeScaling: false,
|
||||||
},
|
},
|
||||||
specialization_tree_rank: {
|
specialization_tree_rank: {
|
||||||
variantKeys: ['base'],
|
variantKeys: ['base'],
|
||||||
allowAssignedAbility: false,
|
allowAssignedAbility: false,
|
||||||
allowProtocolUpgradeScaling: false
|
allowProtocolUpgradeScaling: false,
|
||||||
}
|
},
|
||||||
} as const satisfies Record<AiMechanicSourceKind, {
|
} as const satisfies Record<
|
||||||
variantKeys: readonly string[];
|
AiMechanicSourceKind,
|
||||||
allowAssignedAbility: boolean;
|
{
|
||||||
allowProtocolUpgradeScaling: boolean;
|
variantKeys: readonly string[];
|
||||||
}>;
|
allowAssignedAbility: boolean;
|
||||||
|
allowProtocolUpgradeScaling: boolean;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
export type AiMechanicContext = {
|
export type AiMechanicContext = {
|
||||||
sourceKind: AiMechanicSourceKind;
|
sourceKind: AiMechanicSourceKind;
|
||||||
sourceName: string;
|
sourceName: string;
|
||||||
sourceDescription: string;
|
sourceDescription: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AiMechanicValueColumn = {
|
export type AiMechanicValueColumn = {
|
||||||
key: string;
|
key: string;
|
||||||
variantKey: string;
|
variantKey: string;
|
||||||
label: string;
|
label: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AiMechanicTranscriptEntry = {
|
export type AiMechanicTranscriptEntry = {
|
||||||
questionId: string;
|
questionId: string;
|
||||||
question: string;
|
question: string;
|
||||||
answer: string | number | boolean;
|
answer: string | number | boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AiMechanicAuthoringRequest = {
|
export type AiMechanicAuthoringRequest = {
|
||||||
context: AiMechanicContext;
|
context: AiMechanicContext;
|
||||||
currentDraft: ConditionalMechanicDraftV1;
|
currentDraft: ConditionalMechanicDraftV1;
|
||||||
valueColumns: AiMechanicValueColumn[];
|
valueColumns: AiMechanicValueColumn[];
|
||||||
allowAssignedAbility: boolean;
|
allowAssignedAbility: boolean;
|
||||||
allowProtocolUpgradeScaling: boolean;
|
allowProtocolUpgradeScaling: boolean;
|
||||||
transcript: AiMechanicTranscriptEntry[];
|
transcript: AiMechanicTranscriptEntry[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AiMechanicQuestion = {
|
export type AiMechanicQuestion = {
|
||||||
id: string;
|
id: string;
|
||||||
question: string;
|
question: string;
|
||||||
reason: string;
|
reason: string;
|
||||||
answerType: 'text' | 'number' | 'choice' | 'boolean';
|
answerType: 'text' | 'number' | 'choice' | 'boolean';
|
||||||
options: string[];
|
options: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AiMechanicResponseMeta = {
|
export type AiMechanicResponseMeta = {
|
||||||
requestId: string;
|
requestId: string;
|
||||||
model: string;
|
model: string;
|
||||||
inputTokens: number;
|
inputTokens: number;
|
||||||
outputTokens: number;
|
outputTokens: number;
|
||||||
totalTokens: number;
|
totalTokens: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AiMechanicClarificationResponse = {
|
export type AiMechanicClarificationResponse = {
|
||||||
status: 'clarification_required';
|
status: 'clarification_required';
|
||||||
summary: string;
|
summary: string;
|
||||||
assumptions: string[];
|
assumptions: string[];
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
questions: AiMechanicQuestion[];
|
questions: AiMechanicQuestion[];
|
||||||
meta: AiMechanicResponseMeta;
|
meta: AiMechanicResponseMeta;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AiMechanicDraftResponse = {
|
export type AiMechanicDraftResponse = {
|
||||||
status: 'draft_ready';
|
status: 'draft_ready';
|
||||||
draft: ConditionalMechanicDraftV1;
|
draft: ConditionalMechanicDraftV1;
|
||||||
summary: string;
|
summary: string;
|
||||||
assumptions: string[];
|
assumptions: string[];
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
pseudoScript: string;
|
pseudoScript: string;
|
||||||
meta: AiMechanicResponseMeta;
|
meta: AiMechanicResponseMeta;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AiMechanicCapabilityGap = {
|
export type AiMechanicCapabilityGap = {
|
||||||
area:
|
area:
|
||||||
| 'event'
|
| 'event'
|
||||||
| 'predicate'
|
| 'predicate'
|
||||||
| 'reducer'
|
| 'reducer'
|
||||||
| 'mutation'
|
| 'mutation'
|
||||||
| 'outcome'
|
| 'outcome'
|
||||||
| 'scope'
|
| 'scope'
|
||||||
| 'value_reference'
|
| 'value_reference'
|
||||||
| 'source_variant'
|
| 'source_variant'
|
||||||
| 'runtime';
|
| 'runtime';
|
||||||
requiredBehavior: string;
|
requiredBehavior: string;
|
||||||
whyUnsupported: string;
|
whyUnsupported: string;
|
||||||
developerChange: string;
|
developerChange: string;
|
||||||
workaround: string | null;
|
workaround: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AiMechanicCapabilityGapResponse = {
|
export type AiMechanicCapabilityGapResponse = {
|
||||||
status: 'capability_gap';
|
status: 'capability_gap';
|
||||||
summary: string;
|
summary: string;
|
||||||
assumptions: string[];
|
assumptions: string[];
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
gaps: AiMechanicCapabilityGap[];
|
gaps: AiMechanicCapabilityGap[];
|
||||||
meta: AiMechanicResponseMeta;
|
meta: AiMechanicResponseMeta;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AiMechanicAuthoringResponse =
|
export type AiMechanicAuthoringResponse =
|
||||||
| AiMechanicClarificationResponse
|
| AiMechanicClarificationResponse
|
||||||
| AiMechanicDraftResponse
|
| AiMechanicDraftResponse
|
||||||
| AiMechanicCapabilityGapResponse;
|
| AiMechanicCapabilityGapResponse;
|
||||||
|
|
||||||
export type AiMechanicAuthoringError = {
|
export type AiMechanicAuthoringError = {
|
||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,66 +1,74 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
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 = {
|
const handoff: AiMechanicDeveloperHandoff = {
|
||||||
context: {
|
context: {
|
||||||
sourceKind: 'talent',
|
sourceKind: 'talent',
|
||||||
sourceName: 'Chain Reaction',
|
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' },
|
valueColumns: [
|
||||||
{ key: 'tierII', variantKey: 'II', label: 'TIER II' }
|
{ key: 'tierI', variantKey: 'I', label: 'TIER I' },
|
||||||
],
|
{ key: 'tierII', variantKey: 'II', label: 'TIER II' },
|
||||||
allowAssignedAbility: false,
|
],
|
||||||
allowProtocolUpgradeScaling: false,
|
allowAssignedAbility: false,
|
||||||
transcript: [{
|
allowProtocolUpgradeScaling: false,
|
||||||
questionId: 'transfer-target',
|
transcript: [
|
||||||
question: 'How is the next target selected?',
|
{
|
||||||
answer: 'Nearest enemy'
|
questionId: 'transfer-target',
|
||||||
}],
|
question: 'How is the next target selected?',
|
||||||
response: {
|
answer: 'Nearest enemy',
|
||||||
status: 'capability_gap',
|
},
|
||||||
summary: 'The runtime cannot select a nearby transfer target.',
|
],
|
||||||
assumptions: ['The third hit consumes the current mark.'],
|
response: {
|
||||||
warnings: ['Target selection must be deterministic.'],
|
status: 'capability_gap',
|
||||||
gaps: [{
|
summary: 'The runtime cannot select a nearby transfer target.',
|
||||||
area: 'runtime',
|
assumptions: ['The third hit consumes the current mark.'],
|
||||||
requiredBehavior: 'Select the nearest eligible enemy.',
|
warnings: ['Target selection must be deterministic.'],
|
||||||
whyUnsupported: 'The event has no ordered nearby-target collection.',
|
gaps: [
|
||||||
developerChange: 'Add a deterministic nearby-target selector.',
|
{
|
||||||
workaround: null
|
area: 'runtime',
|
||||||
}],
|
requiredBehavior: 'Select the nearest eligible enemy.',
|
||||||
meta: {
|
whyUnsupported: 'The event has no ordered nearby-target collection.',
|
||||||
requestId: 'req_gap_123',
|
developerChange: 'Add a deterministic nearby-target selector.',
|
||||||
model: 'test-model',
|
workaround: null,
|
||||||
inputTokens: 100,
|
},
|
||||||
outputTokens: 50,
|
],
|
||||||
totalTokens: 150
|
meta: {
|
||||||
}
|
requestId: 'req_gap_123',
|
||||||
}
|
model: 'test-model',
|
||||||
|
inputTokens: 100,
|
||||||
|
outputTokens: 50,
|
||||||
|
totalTokens: 150,
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('mechanic developer handoff prompt', () => {
|
describe('mechanic developer handoff prompt', () => {
|
||||||
it('includes the exact analyzed source, clarification evidence, and required code change', () => {
|
it('includes the exact analyzed source, clarification evidence, and required code change', () => {
|
||||||
const prompt = buildMechanicDeveloperPrompt(handoff);
|
const prompt = buildMechanicDeveloperPrompt(handoff);
|
||||||
|
|
||||||
expect(prompt).toContain(handoff.context.sourceDescription);
|
expect(prompt).toContain(handoff.context.sourceDescription);
|
||||||
expect(prompt).toContain('How is the next target selected?');
|
expect(prompt).toContain('How is the next target selected?');
|
||||||
expect(prompt).toContain('Answer: Nearest enemy');
|
expect(prompt).toContain('Answer: Nearest enemy');
|
||||||
expect(prompt).toContain('Add a deterministic nearby-target selector.');
|
expect(prompt).toContain('Add a deterministic nearby-target selector.');
|
||||||
expect(prompt).toContain('Assistant request ID: req_gap_123');
|
expect(prompt).toContain('Assistant request ID: req_gap_123');
|
||||||
expect(prompt).toContain('Persistence round-trips the new representation');
|
expect(prompt).toContain('Persistence round-trips the new representation');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('omits empty optional sections', () => {
|
it('omits empty optional sections', () => {
|
||||||
const prompt = buildMechanicDeveloperPrompt({
|
const prompt = buildMechanicDeveloperPrompt({
|
||||||
...handoff,
|
...handoff,
|
||||||
transcript: [],
|
transcript: [],
|
||||||
response: { ...handoff.response, assumptions: [], warnings: [] }
|
response: { ...handoff.response, assumptions: [], warnings: [] },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(prompt).not.toContain('## Clarification history');
|
expect(prompt).not.toContain('## Clarification history');
|
||||||
expect(prompt).not.toContain('## Assumptions');
|
expect(prompt).not.toContain('## Assumptions');
|
||||||
expect(prompt).not.toContain('## Warnings');
|
expect(prompt).not.toContain('## Warnings');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,40 +1,55 @@
|
|||||||
import type {
|
import type {
|
||||||
AiMechanicCapabilityGapResponse,
|
AiMechanicCapabilityGapResponse,
|
||||||
AiMechanicContext,
|
AiMechanicContext,
|
||||||
AiMechanicTranscriptEntry,
|
AiMechanicTranscriptEntry,
|
||||||
AiMechanicValueColumn
|
AiMechanicValueColumn,
|
||||||
} from '$lib/ai/mechanic-authoring';
|
} from '$lib/ai/mechanic-authoring';
|
||||||
|
|
||||||
export type AiMechanicDeveloperHandoff = {
|
export type AiMechanicDeveloperHandoff = {
|
||||||
context: AiMechanicContext;
|
context: AiMechanicContext;
|
||||||
valueColumns: AiMechanicValueColumn[];
|
valueColumns: AiMechanicValueColumn[];
|
||||||
allowAssignedAbility: boolean;
|
allowAssignedAbility: boolean;
|
||||||
allowProtocolUpgradeScaling: boolean;
|
allowProtocolUpgradeScaling: boolean;
|
||||||
transcript: AiMechanicTranscriptEntry[];
|
transcript: AiMechanicTranscriptEntry[];
|
||||||
response: AiMechanicCapabilityGapResponse;
|
response: AiMechanicCapabilityGapResponse;
|
||||||
};
|
};
|
||||||
|
|
||||||
function section(title: string, items: string[]) {
|
function section(title: string, items: string[]) {
|
||||||
return items.length ? `\n## ${title}\n\n${items.join('\n')}` : '';
|
return items.length ? `\n## ${title}\n\n${items.join('\n')}` : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildMechanicDeveloperPrompt(handoff: AiMechanicDeveloperHandoff) {
|
export function buildMechanicDeveloperPrompt(
|
||||||
const { context, valueColumns, allowAssignedAbility, allowProtocolUpgradeScaling, transcript, response } = handoff;
|
handoff: AiMechanicDeveloperHandoff,
|
||||||
const variants = valueColumns.map((column) => `${column.label} (${column.variantKey})`).join(', ');
|
) {
|
||||||
const clarifications = transcript.map((entry) =>
|
const {
|
||||||
`- ${entry.question}\n Answer: ${String(entry.answer)}`
|
context,
|
||||||
);
|
valueColumns,
|
||||||
const assumptions = response.assumptions.map((value) => `- ${value}`);
|
allowAssignedAbility,
|
||||||
const warnings = response.warnings.map((value) => `- ${value}`);
|
allowProtocolUpgradeScaling,
|
||||||
const gaps = response.gaps.map((gap, index) => [
|
transcript,
|
||||||
`### Gap ${index + 1}: ${gap.area}`,
|
response,
|
||||||
`- Required behavior: ${gap.requiredBehavior}`,
|
} = handoff;
|
||||||
`- Why unsupported: ${gap.whyUnsupported}`,
|
const variants = valueColumns
|
||||||
`- Developer change: ${gap.developerChange}`,
|
.map((column) => `${column.label} (${column.variantKey})`)
|
||||||
gap.workaround ? `- Current workaround: ${gap.workaround}` : '- Current workaround: None'
|
.join(', ');
|
||||||
].join('\n'));
|
const clarifications = transcript.map(
|
||||||
|
(entry) => `- ${entry.question}\n Answer: ${String(entry.answer)}`,
|
||||||
|
);
|
||||||
|
const assumptions = response.assumptions.map((value) => `- ${value}`);
|
||||||
|
const warnings = response.warnings.map((value) => `- ${value}`);
|
||||||
|
const gaps = response.gaps.map((gap, index) =>
|
||||||
|
[
|
||||||
|
`### Gap ${index + 1}: ${gap.area}`,
|
||||||
|
`- Required behavior: ${gap.requiredBehavior}`,
|
||||||
|
`- Why unsupported: ${gap.whyUnsupported}`,
|
||||||
|
`- Developer change: ${gap.developerChange}`,
|
||||||
|
gap.workaround
|
||||||
|
? `- Current workaround: ${gap.workaround}`
|
||||||
|
: '- Current workaround: None',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
return `Implement support for the following ISAC mechanic-language gap in the SHD.build repository.
|
return `Implement support for the following ISAC mechanic-language gap in the SHD.build repository.
|
||||||
|
|
||||||
Start by tracing the existing typed mechanic grammar, persistence adapter, runtime, editor, AI authoring context, and tests. Extend the smallest reusable layer that can represent the behavior exactly. Do not invent combat behavior or weaken deterministic validation. Preserve manual mechanic authoring and existing saved game data. Update shared registries, types, validation, persistence, runtime evaluation, editor controls, AI prompt context, tests, and maintained architecture/design documentation wherever the implementation requires them. Follow the repository's audit, database-preservation, accessibility, and development-server rules.
|
Start by tracing the existing typed mechanic grammar, persistence adapter, runtime, editor, AI authoring context, and tests. Extend the smallest reusable layer that can represent the behavior exactly. Do not invent combat behavior or weaken deterministic validation. Preserve manual mechanic authoring and existing saved game data. Update shared registries, types, validation, persistence, runtime evaluation, editor controls, AI prompt context, tests, and maintained architecture/design documentation wherever the implementation requires them. Follow the repository's audit, database-preservation, accessibility, and development-server rules.
|
||||||
|
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
+81
-21
@@ -1,29 +1,89 @@
|
|||||||
export const AVATAR_PRESETS = [
|
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: 'field-agent',
|
||||||
{ id: 'systems-engineer', label: 'Systems Engineer', url: '/avatars/default/systems-engineer.webp' },
|
label: 'Field Agent',
|
||||||
{ id: 'recon-scout', label: 'Recon Scout', url: '/avatars/default/recon-scout.webp' },
|
url: '/avatars/default/field-agent.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: 'field-medic',
|
||||||
{ id: 'equipment-engineer', label: 'Equipment Engineer', url: '/avatars/default/equipment-engineer.webp' },
|
label: 'Field Medic',
|
||||||
{ id: 'wilderness-tracker', label: 'Wilderness Tracker', url: '/avatars/default/wilderness-tracker.webp' },
|
url: '/avatars/default/field-medic.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: '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;
|
] as const;
|
||||||
|
|
||||||
export const DEFAULT_AVATAR_PRESET = AVATAR_PRESETS[0].id;
|
export const DEFAULT_AVATAR_PRESET = AVATAR_PRESETS[0].id;
|
||||||
|
|
||||||
export function isAvatarPreset(value: string): value is (typeof AVATAR_PRESETS)[number]['id'] {
|
export function isAvatarPreset(
|
||||||
return AVATAR_PRESETS.some((preset) => preset.id === value);
|
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(
|
||||||
if (source === 'upload') {
|
source: unknown,
|
||||||
const version = updatedAt ? `?v=${encodeURIComponent(String(updatedAt))}` : '';
|
value: unknown,
|
||||||
return `/avatars/user/${encodeURIComponent(userId)}${version}`;
|
userId: string,
|
||||||
}
|
updatedAt?: unknown,
|
||||||
const preset = typeof value === 'string' && isAvatarPreset(value) ? value : DEFAULT_AVATAR_PRESET;
|
): string {
|
||||||
return `/avatars/default/${preset}.webp`;
|
if (source === 'upload') {
|
||||||
|
const version = updatedAt
|
||||||
|
? `?v=${encodeURIComponent(String(updatedAt))}`
|
||||||
|
: '';
|
||||||
|
return `/avatars/user/${encodeURIComponent(userId)}${version}`;
|
||||||
|
}
|
||||||
|
const preset =
|
||||||
|
typeof value === 'string' && isAvatarPreset(value)
|
||||||
|
? value
|
||||||
|
: DEFAULT_AVATAR_PRESET;
|
||||||
|
return `/avatars/default/${preset}.webp`;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+1665
-1213
File diff suppressed because it is too large
Load Diff
@@ -4,76 +4,78 @@ import { createDraft, parseLoadoutDraft } from './loadout.js';
|
|||||||
const copyDraft = () => structuredClone(createDraft());
|
const copyDraft = () => structuredClone(createDraft());
|
||||||
|
|
||||||
describe('parseLoadoutDraft', () => {
|
describe('parseLoadoutDraft', () => {
|
||||||
it('accepts a current draft and returns an independent clone', () => {
|
it('accepts a current draft and returns an independent clone', () => {
|
||||||
const draft = createDraft();
|
const draft = createDraft();
|
||||||
const parsed = parseLoadoutDraft(draft);
|
const parsed = parseLoadoutDraft(draft);
|
||||||
|
|
||||||
expect(parsed).toEqual(draft);
|
expect(parsed).toEqual(draft);
|
||||||
expect(parsed?.calculationMode).toBe('maximum');
|
expect(parsed?.calculationMode).toBe('maximum');
|
||||||
expect(parsed).not.toBe(draft);
|
expect(parsed).not.toBe(draft);
|
||||||
parsed!.gear.Holster.level = 1;
|
parsed!.gear.Holster.level = 1;
|
||||||
expect(draft.gear.Holster.level).toBe(50);
|
expect(draft.gear.Holster.level).toBe(50);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects the retired version 1 three-chip shape', () => {
|
it('rejects the retired version 1 three-chip shape', () => {
|
||||||
const retired = copyDraft() as unknown as Record<string, unknown>;
|
const retired = copyDraft() as unknown as Record<string, unknown>;
|
||||||
retired.version = 1;
|
retired.version = 1;
|
||||||
retired.chips = (retired.chips as unknown[]).slice(0, 3);
|
retired.chips = (retired.chips as unknown[]).slice(0, 3);
|
||||||
|
|
||||||
expect(parseLoadoutDraft(retired)).toBeNull();
|
expect(parseLoadoutDraft(retired)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a version 4 draft that does not contain exactly nine chips', () => {
|
it('rejects a version 4 draft that does not contain exactly nine chips', () => {
|
||||||
const draft = copyDraft();
|
const draft = copyDraft();
|
||||||
draft.chips.pop();
|
draft.chips.pop();
|
||||||
|
|
||||||
expect(parseLoadoutDraft(draft)).toBeNull();
|
expect(parseLoadoutDraft(draft)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a draft missing the unified combat profile', () => {
|
it('rejects a draft missing the unified combat profile', () => {
|
||||||
const draft = copyDraft() as unknown as Record<string, unknown>;
|
const draft = copyDraft() as unknown as Record<string, unknown>;
|
||||||
delete draft.combatProfile;
|
delete draft.combatProfile;
|
||||||
|
|
||||||
expect(parseLoadoutDraft(draft)).toBeNull();
|
expect(parseLoadoutDraft(draft)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accepts a custom scenario and preserves its assumptions', () => {
|
it('accepts a custom scenario and preserves its assumptions', () => {
|
||||||
const draft = copyDraft();
|
const draft = copyDraft();
|
||||||
draft.calculationMode = 'custom';
|
draft.calculationMode = 'custom';
|
||||||
draft.combatProfile.hitRate = 63;
|
draft.combatProfile.hitRate = 63;
|
||||||
draft.combatProfile.targetDistanceMeters = 28;
|
draft.combatProfile.targetDistanceMeters = 28;
|
||||||
draft.conditionalEffects.relentless = {
|
draft.conditionalEffects.relentless = {
|
||||||
mode: 'encounter',
|
mode: 'encounter',
|
||||||
inputs: {
|
inputs: {
|
||||||
alliesWithinRange: 3,
|
alliesWithinRange: 3,
|
||||||
targetHasDebuff: true
|
targetHasDebuff: true,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(parseLoadoutDraft(draft)).toEqual(draft);
|
expect(parseLoadoutDraft(draft)).toEqual(draft);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects drafts without the required calculation model', () => {
|
it('rejects drafts without the required calculation model', () => {
|
||||||
const missing = copyDraft() as unknown as Record<string, unknown>;
|
const missing = copyDraft() as unknown as Record<string, unknown>;
|
||||||
delete missing.calculationMode;
|
delete missing.calculationMode;
|
||||||
expect(parseLoadoutDraft(missing)).toBeNull();
|
expect(parseLoadoutDraft(missing)).toBeNull();
|
||||||
|
|
||||||
const invalid = copyDraft() as unknown as Record<string, unknown>;
|
const invalid = copyDraft() as unknown as Record<string, unknown>;
|
||||||
invalid.calculationMode = 'legacy';
|
invalid.calculationMode = 'legacy';
|
||||||
expect(parseLoadoutDraft(invalid)).toBeNull();
|
expect(parseLoadoutDraft(invalid)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a specialization that carries retired unassigned chips', () => {
|
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 {
|
||||||
draft.specialization.unassignedChips = [];
|
specialization: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
draft.specialization.unassignedChips = [];
|
||||||
|
|
||||||
expect(parseLoadoutDraft(draft)).toBeNull();
|
expect(parseLoadoutDraft(draft)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects malformed nested equipment selections', () => {
|
it('rejects malformed nested equipment selections', () => {
|
||||||
const draft = copyDraft();
|
const draft = copyDraft();
|
||||||
draft.weapons[0].rolls[1] = { statId: 'weapon-damage', value: Number.NaN };
|
draft.weapons[0].rolls[1] = { statId: 'weapon-damage', value: Number.NaN };
|
||||||
|
|
||||||
expect(parseLoadoutDraft(draft)).toBeNull();
|
expect(parseLoadoutDraft(draft)).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+462
-227
@@ -1,279 +1,514 @@
|
|||||||
export type Roll = { statId: string; value: number };
|
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 GearSelection = {
|
||||||
export type WeaponSelection = { itemId: string; level: number; tier: number; damage: number; firepower: number; toughness: number; engineering: number; talent1Id: string; talent2Id: string; rolls: [Roll, Roll] };
|
itemId: string;
|
||||||
export type ChipSelection = { setId: string; quality: 'Superior' | 'High-end'; primary: Roll; secondary: [Roll, Roll] };
|
level: number;
|
||||||
export type ProtocolSelection = { itemId: string; level: number; talentUpgrades: 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 = {
|
export type CombatProfile = {
|
||||||
encounterSeconds: number;
|
encounterSeconds: number;
|
||||||
hitRate: number;
|
hitRate: number;
|
||||||
headshotRate: number;
|
headshotRate: number;
|
||||||
shotsPerBurst: number;
|
shotsPerBurst: number;
|
||||||
burstPauseSeconds: number;
|
burstPauseSeconds: number;
|
||||||
sameTargetSeconds: number;
|
sameTargetSeconds: number;
|
||||||
distinctTargetsPerMinute: number;
|
distinctTargetsPerMinute: number;
|
||||||
killsPerMinute: number;
|
killsPerMinute: number;
|
||||||
headshotKillRate: number;
|
headshotKillRate: number;
|
||||||
damageTakenPerMinute: number;
|
damageTakenPerMinute: number;
|
||||||
swapsPerMinute: number;
|
swapsPerMinute: number;
|
||||||
targetDistanceMeters: number;
|
targetDistanceMeters: number;
|
||||||
healthPercent: number;
|
healthPercent: number;
|
||||||
extraHealthActive: boolean;
|
extraHealthActive: boolean;
|
||||||
};
|
};
|
||||||
export type CalculationMode = 'maximum' | 'custom';
|
export type CalculationMode = 'maximum' | 'custom';
|
||||||
export type EffectMode = 'off' | 'peak' | 'encounter';
|
export type EffectMode = 'off' | 'peak' | 'encounter';
|
||||||
export type ConditionalEffectSelection = {
|
export type ConditionalEffectSelection = {
|
||||||
mode: EffectMode;
|
mode: EffectMode;
|
||||||
overrides?: Partial<CombatProfile>;
|
overrides?: Partial<CombatProfile>;
|
||||||
inputs?: Record<string, number | boolean>;
|
inputs?: Record<string, number | boolean>;
|
||||||
};
|
};
|
||||||
export type SkillActivationSelection = {
|
export type SkillActivationSelection = {
|
||||||
mode: 'off' | 'on_cooldown' | 'custom_per_minute';
|
mode: 'off' | 'on_cooldown' | 'custom_per_minute';
|
||||||
usesPerMinute?: number;
|
usesPerMinute?: number;
|
||||||
targets: number;
|
targets: number;
|
||||||
};
|
};
|
||||||
export type SpecializationSelection = {
|
export type SpecializationSelection = {
|
||||||
focusId: string;
|
focusId: string;
|
||||||
nodeRanks: Record<string, number>;
|
nodeRanks: Record<string, number>;
|
||||||
};
|
};
|
||||||
export type LoadoutDraft = {
|
export type LoadoutDraft = {
|
||||||
version: 4;
|
version: 4;
|
||||||
buildName: string;
|
buildName: string;
|
||||||
calculationMode: CalculationMode;
|
calculationMode: CalculationMode;
|
||||||
gear: Record<string, GearSelection>;
|
gear: Record<string, GearSelection>;
|
||||||
weapons: [WeaponSelection, WeaponSelection];
|
weapons: [WeaponSelection, WeaponSelection];
|
||||||
chips: [ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection];
|
chips: [
|
||||||
protocol: ProtocolSelection;
|
ChipSelection,
|
||||||
conditionalEffects: Record<string, ConditionalEffectSelection>;
|
ChipSelection,
|
||||||
combatProfile: CombatProfile;
|
ChipSelection,
|
||||||
skillActivations: Record<string, SkillActivationSelection>;
|
ChipSelection,
|
||||||
specialization: SpecializationSelection;
|
ChipSelection,
|
||||||
|
ChipSelection,
|
||||||
|
ChipSelection,
|
||||||
|
ChipSelection,
|
||||||
|
ChipSelection,
|
||||||
|
];
|
||||||
|
protocol: ProtocolSelection;
|
||||||
|
conditionalEffects: Record<string, ConditionalEffectSelection>;
|
||||||
|
combatProfile: CombatProfile;
|
||||||
|
skillActivations: Record<string, SkillActivationSelection>;
|
||||||
|
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 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 blankGear = (): GearSelection => ({
|
||||||
export const blankWeapon = (): WeaponSelection => ({ itemId: '', level: 50, tier: 0, damage: 0, firepower: 0, toughness: 0, engineering: 0, talent1Id: '', talent2Id: '', rolls: [blankRoll(), blankRoll()] });
|
itemId: '',
|
||||||
export const blankChip = (): ChipSelection => ({ setId: '', quality: 'Superior', primary: blankRoll(), secondary: [blankRoll(), blankRoll()] });
|
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 => ({
|
export const defaultCombatProfile = (): CombatProfile => ({
|
||||||
encounterSeconds: 60,
|
encounterSeconds: 60,
|
||||||
hitRate: 75,
|
hitRate: 75,
|
||||||
headshotRate: 20,
|
headshotRate: 20,
|
||||||
shotsPerBurst: 30,
|
shotsPerBurst: 30,
|
||||||
burstPauseSeconds: 0,
|
burstPauseSeconds: 0,
|
||||||
sameTargetSeconds: 5,
|
sameTargetSeconds: 5,
|
||||||
distinctTargetsPerMinute: 12,
|
distinctTargetsPerMinute: 12,
|
||||||
killsPerMinute: 4,
|
killsPerMinute: 4,
|
||||||
headshotKillRate: 20,
|
headshotKillRate: 20,
|
||||||
damageTakenPerMinute: 6,
|
damageTakenPerMinute: 6,
|
||||||
swapsPerMinute: 2,
|
swapsPerMinute: 2,
|
||||||
targetDistanceMeters: 15,
|
targetDistanceMeters: 15,
|
||||||
healthPercent: 100,
|
healthPercent: 100,
|
||||||
extraHealthActive: false
|
extraHealthActive: false,
|
||||||
});
|
});
|
||||||
export const createDraft = (): LoadoutDraft => ({
|
export const createDraft = (): LoadoutDraft => ({
|
||||||
version: 4,
|
version: 4,
|
||||||
buildName: 'Untitled loadout',
|
buildName: 'Untitled loadout',
|
||||||
calculationMode: 'maximum',
|
calculationMode: 'maximum',
|
||||||
gear: Object.fromEntries(gearSlots.map((slot) => [slot, blankGear()])),
|
gear: Object.fromEntries(gearSlots.map((slot) => [slot, blankGear()])),
|
||||||
weapons: [blankWeapon(), blankWeapon()],
|
weapons: [blankWeapon(), blankWeapon()],
|
||||||
chips: [blankChip(), blankChip(), blankChip(), blankChip(), blankChip(), blankChip(), blankChip(), blankChip(), blankChip()],
|
chips: [
|
||||||
protocol: { itemId: '', level: 1, talentUpgrades: 0 },
|
blankChip(),
|
||||||
conditionalEffects: {},
|
blankChip(),
|
||||||
combatProfile: defaultCombatProfile(),
|
blankChip(),
|
||||||
skillActivations: {},
|
blankChip(),
|
||||||
specialization: {
|
blankChip(),
|
||||||
focusId: '',
|
blankChip(),
|
||||||
nodeRanks: {}
|
blankChip(),
|
||||||
}
|
blankChip(),
|
||||||
|
blankChip(),
|
||||||
|
],
|
||||||
|
protocol: { itemId: '', level: 1, talentUpgrades: 0 },
|
||||||
|
conditionalEffects: {},
|
||||||
|
combatProfile: defaultCombatProfile(),
|
||||||
|
skillActivations: {},
|
||||||
|
specialization: {
|
||||||
|
focusId: '',
|
||||||
|
nodeRanks: {},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||||
const hasExactKeys = (value: Record<string, unknown>, keys: string[]) =>
|
const hasExactKeys = (value: Record<string, unknown>, keys: string[]) =>
|
||||||
Object.keys(value).length === keys.length && keys.every((key) => key in value);
|
Object.keys(value).length === keys.length &&
|
||||||
const isFiniteNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value);
|
keys.every((key) => key in value);
|
||||||
|
const isFiniteNumber = (value: unknown): value is number =>
|
||||||
|
typeof value === 'number' && Number.isFinite(value);
|
||||||
const isNumberRecord = (value: unknown) =>
|
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 =>
|
const isRoll = (value: unknown): value is Roll =>
|
||||||
isRecord(value)
|
isRecord(value) &&
|
||||||
&& hasExactKeys(value, ['statId', 'value'])
|
hasExactKeys(value, ['statId', 'value']) &&
|
||||||
&& typeof value.statId === 'string'
|
typeof value.statId === 'string' &&
|
||||||
&& isFiniteNumber(value.value);
|
isFiniteNumber(value.value);
|
||||||
const isRollPair = (value: unknown): value is [Roll, Roll] =>
|
const isRollPair = (value: unknown): value is [Roll, Roll] =>
|
||||||
Array.isArray(value) && value.length === 2 && value.every(isRoll);
|
Array.isArray(value) && value.length === 2 && value.every(isRoll);
|
||||||
const isGearSelection = (value: unknown): value is GearSelection =>
|
const isGearSelection = (value: unknown): value is GearSelection =>
|
||||||
isRecord(value)
|
isRecord(value) &&
|
||||||
&& hasExactKeys(value, ['itemId', 'level', 'brandId', 'talentId', 'health', 'armor', 'firepower', 'toughness', 'engineering', 'rolls', 'augmentSlots'])
|
hasExactKeys(value, [
|
||||||
&& ['itemId', 'brandId', 'talentId'].every((key) => typeof value[key] === 'string')
|
'itemId',
|
||||||
&& ['level', 'health', 'armor', 'firepower', 'toughness', 'engineering'].every((key) => isFiniteNumber(value[key]))
|
'level',
|
||||||
&& isRollPair(value.rolls)
|
'brandId',
|
||||||
&& Array.isArray(value.augmentSlots)
|
'talentId',
|
||||||
&& value.augmentSlots.every(isFiniteNumber);
|
'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 =>
|
const isWeaponSelection = (value: unknown): value is WeaponSelection =>
|
||||||
isRecord(value)
|
isRecord(value) &&
|
||||||
&& hasExactKeys(value, ['itemId', 'level', 'tier', 'damage', 'firepower', 'toughness', 'engineering', 'talent1Id', 'talent2Id', 'rolls'])
|
hasExactKeys(value, [
|
||||||
&& ['itemId', 'talent1Id', 'talent2Id'].every((key) => typeof value[key] === 'string')
|
'itemId',
|
||||||
&& ['level', 'tier', 'damage', 'firepower', 'toughness', 'engineering'].every((key) => isFiniteNumber(value[key]))
|
'level',
|
||||||
&& isRollPair(value.rolls);
|
'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 =>
|
const isChipSelection = (value: unknown): value is ChipSelection =>
|
||||||
isRecord(value)
|
isRecord(value) &&
|
||||||
&& hasExactKeys(value, ['setId', 'quality', 'primary', 'secondary'])
|
hasExactKeys(value, ['setId', 'quality', 'primary', 'secondary']) &&
|
||||||
&& typeof value.setId === 'string'
|
typeof value.setId === 'string' &&
|
||||||
&& (value.quality === 'Superior' || value.quality === 'High-end')
|
(value.quality === 'Superior' || value.quality === 'High-end') &&
|
||||||
&& isRoll(value.primary)
|
isRoll(value.primary) &&
|
||||||
&& isRollPair(value.secondary);
|
isRollPair(value.secondary);
|
||||||
const isProtocolSelection = (value: unknown): value is ProtocolSelection =>
|
const isProtocolSelection = (value: unknown): value is ProtocolSelection =>
|
||||||
isRecord(value)
|
isRecord(value) &&
|
||||||
&& hasExactKeys(value, ['itemId', 'level', 'talentUpgrades'])
|
hasExactKeys(value, ['itemId', 'level', 'talentUpgrades']) &&
|
||||||
&& typeof value.itemId === 'string'
|
typeof value.itemId === 'string' &&
|
||||||
&& isFiniteNumber(value.level)
|
isFiniteNumber(value.level) &&
|
||||||
&& isFiniteNumber(value.talentUpgrades);
|
isFiniteNumber(value.talentUpgrades);
|
||||||
const isCombatProfile = (value: unknown): value is CombatProfile =>
|
const isCombatProfile = (value: unknown): value is CombatProfile =>
|
||||||
isRecord(value)
|
isRecord(value) &&
|
||||||
&& hasExactKeys(value, [
|
hasExactKeys(value, [
|
||||||
'encounterSeconds', 'hitRate', 'headshotRate', 'shotsPerBurst', 'burstPauseSeconds',
|
'encounterSeconds',
|
||||||
'sameTargetSeconds', 'distinctTargetsPerMinute', 'killsPerMinute', 'headshotKillRate', 'damageTakenPerMinute',
|
'hitRate',
|
||||||
'swapsPerMinute', 'targetDistanceMeters', 'healthPercent', 'extraHealthActive'
|
'headshotRate',
|
||||||
])
|
'shotsPerBurst',
|
||||||
&& [
|
'burstPauseSeconds',
|
||||||
'encounterSeconds', 'hitRate', 'headshotRate', 'shotsPerBurst', 'burstPauseSeconds',
|
'sameTargetSeconds',
|
||||||
'sameTargetSeconds', 'distinctTargetsPerMinute', 'killsPerMinute', 'headshotKillRate', 'damageTakenPerMinute',
|
'distinctTargetsPerMinute',
|
||||||
'swapsPerMinute', 'targetDistanceMeters', 'healthPercent'
|
'killsPerMinute',
|
||||||
].every((key) => isFiniteNumber(value[key]))
|
'headshotKillRate',
|
||||||
&& typeof value.extraHealthActive === 'boolean';
|
'damageTakenPerMinute',
|
||||||
const isEffectStates = (value: unknown): value is Record<string, ConditionalEffectSelection> =>
|
'swapsPerMinute',
|
||||||
isRecord(value) && Object.values(value).every((entry) => {
|
'targetDistanceMeters',
|
||||||
if (!isRecord(entry)
|
'healthPercent',
|
||||||
|| !['off', 'peak', 'encounter'].includes(String(entry.mode))) return false;
|
'extraHealthActive',
|
||||||
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;
|
'encounterSeconds',
|
||||||
if (entry.inputs !== undefined && (
|
'hitRate',
|
||||||
!isRecord(entry.inputs)
|
'headshotRate',
|
||||||
|| Object.values(entry.inputs).some((input) =>
|
'shotsPerBurst',
|
||||||
typeof input !== 'boolean' && !isFiniteNumber(input)
|
'burstPauseSeconds',
|
||||||
)
|
'sameTargetSeconds',
|
||||||
)) return false;
|
'distinctTargetsPerMinute',
|
||||||
return entry.overrides === undefined || (
|
'killsPerMinute',
|
||||||
isRecord(entry.overrides)
|
'headshotKillRate',
|
||||||
&& Object.entries(entry.overrides).every(([key, override]) =>
|
'damageTakenPerMinute',
|
||||||
key === 'extraHealthActive'
|
'swapsPerMinute',
|
||||||
? typeof override === 'boolean'
|
'targetDistanceMeters',
|
||||||
: key in defaultCombatProfile() && isFiniteNumber(override)
|
'healthPercent',
|
||||||
)
|
].every((key) => isFiniteNumber(value[key])) &&
|
||||||
);
|
typeof value.extraHealthActive === 'boolean';
|
||||||
});
|
const isEffectStates = (
|
||||||
const isSkillActivations = (value: unknown): value is Record<string, SkillActivationSelection> =>
|
value: unknown,
|
||||||
isRecord(value) && Object.values(value).every((entry) =>
|
): value is Record<string, ConditionalEffectSelection> =>
|
||||||
isRecord(entry)
|
isRecord(value) &&
|
||||||
&& ['off', 'on_cooldown', 'custom_per_minute'].includes(String(entry.mode))
|
Object.values(value).every((entry) => {
|
||||||
&& isFiniteNumber(entry.targets)
|
if (
|
||||||
&& (entry.usesPerMinute === undefined || isFiniteNumber(entry.usesPerMinute))
|
!isRecord(entry) ||
|
||||||
&& Object.keys(entry).every((key) => ['mode', 'usesPerMinute', 'targets'].includes(key))
|
!['off', 'peak', 'encounter'].includes(String(entry.mode))
|
||||||
);
|
)
|
||||||
|
return false;
|
||||||
|
const allowed = new Set(['mode', 'overrides', 'inputs']);
|
||||||
|
if (
|
||||||
|
Object.keys(entry).some((key) => !allowed.has(key)) ||
|
||||||
|
!['off', 'peak', 'encounter'].includes(String(entry.mode))
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
if (
|
||||||
|
entry.inputs !== undefined &&
|
||||||
|
(!isRecord(entry.inputs) ||
|
||||||
|
Object.values(entry.inputs).some(
|
||||||
|
(input) => typeof input !== 'boolean' && !isFiniteNumber(input),
|
||||||
|
))
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
return (
|
||||||
|
entry.overrides === undefined ||
|
||||||
|
(isRecord(entry.overrides) &&
|
||||||
|
Object.entries(entry.overrides).every(([key, override]) =>
|
||||||
|
key === 'extraHealthActive'
|
||||||
|
? typeof override === 'boolean'
|
||||||
|
: key in defaultCombatProfile() && isFiniteNumber(override),
|
||||||
|
))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const isSkillActivations = (
|
||||||
|
value: unknown,
|
||||||
|
): value is Record<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 {
|
export function parseLoadoutDraft(value: unknown): LoadoutDraft | null {
|
||||||
if (!isRecord(value)
|
if (
|
||||||
|| !hasExactKeys(value, [
|
!isRecord(value) ||
|
||||||
'version', 'buildName', 'calculationMode', 'gear', 'weapons', 'chips', 'protocol',
|
!hasExactKeys(value, [
|
||||||
'conditionalEffects', 'combatProfile', 'skillActivations', 'specialization'
|
'version',
|
||||||
])
|
'buildName',
|
||||||
|| value.version !== 4
|
'calculationMode',
|
||||||
|| typeof value.buildName !== 'string'
|
'gear',
|
||||||
|| !['maximum', 'custom'].includes(String(value.calculationMode))) return null;
|
'weapons',
|
||||||
const gear = value.gear;
|
'chips',
|
||||||
if (!isRecord(gear) || Object.keys(gear).length !== gearSlots.length
|
'protocol',
|
||||||
|| !gearSlots.every((slot) => isGearSelection(gear[slot]))) return null;
|
'conditionalEffects',
|
||||||
if (!Array.isArray(value.weapons) || value.weapons.length !== 2 || !value.weapons.every(isWeaponSelection)) return null;
|
'combatProfile',
|
||||||
if (!Array.isArray(value.chips) || value.chips.length !== 9 || !value.chips.every(isChipSelection)) return null;
|
'skillActivations',
|
||||||
if (!isProtocolSelection(value.protocol)
|
'specialization',
|
||||||
|| !isEffectStates(value.conditionalEffects)
|
]) ||
|
||||||
|| !isCombatProfile(value.combatProfile)
|
value.version !== 4 ||
|
||||||
|| !isSkillActivations(value.skillActivations)) return null;
|
typeof value.buildName !== 'string' ||
|
||||||
if (!isRecord(value.specialization)
|
!['maximum', 'custom'].includes(String(value.calculationMode))
|
||||||
|| !hasExactKeys(value.specialization, ['focusId', 'nodeRanks'])
|
)
|
||||||
|| typeof value.specialization.focusId !== 'string'
|
return null;
|
||||||
|| !isNumberRecord(value.specialization.nodeRanks)) return null;
|
const gear = value.gear;
|
||||||
return structuredClone(value) as LoadoutDraft;
|
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) => {
|
export const clamp = (value: unknown, min: unknown, max: unknown) => {
|
||||||
const low = Number(min), high = Number(max), numeric = Number(value);
|
const low = Number(min),
|
||||||
if (!Number.isFinite(numeric)) return Number.isFinite(low) ? low : 0;
|
high = Number(max),
|
||||||
return Math.min(Number.isFinite(high) ? high : numeric, Math.max(Number.isFinite(low) ? low : numeric, numeric));
|
numeric = Number(value);
|
||||||
|
if (!Number.isFinite(numeric)) return Number.isFinite(low) ? low : 0;
|
||||||
|
return Math.min(
|
||||||
|
Number.isFinite(high) ? high : numeric,
|
||||||
|
Math.max(Number.isFinite(low) ? low : numeric, numeric),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const scaled = (min: unknown, max: unknown, progress: number, steps: number) =>
|
export const scaled = (
|
||||||
Number(min) + (Number(max) - Number(min)) * Math.min(steps, Math.max(0, progress)) / steps;
|
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 = {
|
export type LevelScaledRange = {
|
||||||
rawMin: number;
|
rawMin: number;
|
||||||
rawMax: number;
|
rawMax: number;
|
||||||
min: number;
|
min: number;
|
||||||
max: number;
|
max: number;
|
||||||
provisional: boolean;
|
provisional: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const itemLevel = (value: unknown) => Math.round(clamp(value, 1, 50));
|
const itemLevel = (value: unknown) => Math.round(clamp(value, 1, 50));
|
||||||
export const gearMultiplier = (level: unknown) => 0.91 + 0.05 * itemLevel(level);
|
export const gearMultiplier = (level: unknown) =>
|
||||||
export const weaponMultiplier = (level: unknown) => 1.14 + 0.05 * itemLevel(level);
|
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(
|
||||||
const target = itemLevel(targetLevel);
|
minimum: unknown,
|
||||||
const configuredMin = Number(minimum);
|
maximum: unknown,
|
||||||
const configuredMax = Number(maximum);
|
targetLevel: unknown,
|
||||||
const rawMin = (Number.isFinite(configuredMin) ? configuredMin : 0) * gearMultiplier(target);
|
): LevelScaledRange {
|
||||||
const rawMax = (Number.isFinite(configuredMax) ? configuredMax : 0) * gearMultiplier(target);
|
const target = itemLevel(targetLevel);
|
||||||
return {
|
const configuredMin = Number(minimum);
|
||||||
rawMin,
|
const configuredMax = Number(maximum);
|
||||||
rawMax,
|
const rawMin =
|
||||||
min: Math.floor(rawMin),
|
(Number.isFinite(configuredMin) ? configuredMin : 0) *
|
||||||
max: Math.floor(rawMax),
|
gearMultiplier(target);
|
||||||
provisional: target < 10
|
const rawMax =
|
||||||
};
|
(Number.isFinite(configuredMax) ? configuredMax : 0) *
|
||||||
|
gearMultiplier(target);
|
||||||
|
return {
|
||||||
|
rawMin,
|
||||||
|
rawMax,
|
||||||
|
min: Math.floor(rawMin),
|
||||||
|
max: Math.floor(rawMax),
|
||||||
|
provisional: target < 10,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function weaponBaseRange(minimum: unknown, maximum: unknown, targetLevel: unknown): LevelScaledRange {
|
export function weaponBaseRange(
|
||||||
const target = itemLevel(targetLevel);
|
minimum: unknown,
|
||||||
const configuredMin = Number(minimum);
|
maximum: unknown,
|
||||||
const configuredMax = Number(maximum);
|
targetLevel: unknown,
|
||||||
const rawMin = (Number.isFinite(configuredMin) ? configuredMin : 0) * weaponMultiplier(target);
|
): LevelScaledRange {
|
||||||
const rawMax = (Number.isFinite(configuredMax) ? configuredMax : 0) * weaponMultiplier(target);
|
const target = itemLevel(targetLevel);
|
||||||
return {
|
const configuredMin = Number(minimum);
|
||||||
rawMin,
|
const configuredMax = Number(maximum);
|
||||||
rawMax,
|
const rawMin =
|
||||||
min: Math.floor(rawMin),
|
(Number.isFinite(configuredMin) ? configuredMin : 0) *
|
||||||
max: Math.floor(rawMax),
|
weaponMultiplier(target);
|
||||||
provisional: target < 40
|
const rawMax =
|
||||||
};
|
(Number.isFinite(configuredMax) ? configuredMax : 0) *
|
||||||
|
weaponMultiplier(target);
|
||||||
|
return {
|
||||||
|
rawMin,
|
||||||
|
rawMax,
|
||||||
|
min: Math.floor(rawMin),
|
||||||
|
max: Math.floor(rawMax),
|
||||||
|
provisional: target < 40,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function levelScaledRange(
|
export function levelScaledRange(
|
||||||
minimum: unknown,
|
minimum: unknown,
|
||||||
maximum: unknown,
|
maximum: unknown,
|
||||||
targetLevel: unknown,
|
targetLevel: unknown,
|
||||||
referenceLevel: unknown,
|
referenceLevel: unknown,
|
||||||
source: 'gear' | 'weapon'
|
source: 'gear' | 'weapon',
|
||||||
): LevelScaledRange {
|
): LevelScaledRange {
|
||||||
const target = itemLevel(targetLevel);
|
const target = itemLevel(targetLevel);
|
||||||
const reference = itemLevel(referenceLevel);
|
const reference = itemLevel(referenceLevel);
|
||||||
const multiplier = source === 'gear' ? gearMultiplier : weaponMultiplier;
|
const multiplier = source === 'gear' ? gearMultiplier : weaponMultiplier;
|
||||||
const ratio = multiplier(target) / multiplier(reference);
|
const ratio = multiplier(target) / multiplier(reference);
|
||||||
const configuredMin = Number(minimum);
|
const configuredMin = Number(minimum);
|
||||||
const configuredMax = Number(maximum);
|
const configuredMax = Number(maximum);
|
||||||
const rawMin = (Number.isFinite(configuredMin) ? configuredMin : 0) * ratio;
|
const rawMin = (Number.isFinite(configuredMin) ? configuredMin : 0) * ratio;
|
||||||
const rawMax = (Number.isFinite(configuredMax) ? configuredMax : 0) * ratio;
|
const rawMax = (Number.isFinite(configuredMax) ? configuredMax : 0) * ratio;
|
||||||
return {
|
return {
|
||||||
rawMin,
|
rawMin,
|
||||||
rawMax,
|
rawMax,
|
||||||
min: Math.floor(rawMin),
|
min: Math.floor(rawMin),
|
||||||
max: Math.floor(rawMax),
|
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(
|
||||||
const span = from.max - from.min;
|
value: unknown,
|
||||||
const position = span > 0 ? (clamp(value, from.min, from.max) - from.min) / span : 0;
|
from: LevelScaledRange,
|
||||||
return Math.round(to.min + (to.max - to.min) * position);
|
to: LevelScaledRange,
|
||||||
|
) {
|
||||||
|
const span = from.max - from.min;
|
||||||
|
const position =
|
||||||
|
span > 0 ? (clamp(value, from.min, from.max) - from.min) / span : 0;
|
||||||
|
return Math.round(to.min + (to.max - to.min) * position);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +1,66 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
formatItemDamageRangeScript,
|
formatItemDamageRangeScript,
|
||||||
formatWeaponCalculationScript
|
formatWeaponCalculationScript,
|
||||||
} from './calculation-script';
|
} from './calculation-script';
|
||||||
|
|
||||||
describe('formatWeaponCalculationScript', () => {
|
describe('formatWeaponCalculationScript', () => {
|
||||||
it('starts from the selected equipped damage and explains ordered typed stages', () => {
|
it('starts from the selected equipped damage and explains ordered typed stages', () => {
|
||||||
const lines = formatWeaponCalculationScript({
|
const lines = formatWeaponCalculationScript({
|
||||||
revision: { version: 4, name: 'Resurgence model' },
|
revision: { version: 4, name: 'Resurgence model' },
|
||||||
stages: [
|
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: 'base',
|
||||||
{ stage_key: 'cycle', label: 'Firing cycle', operator: 'firing_cycle', display_order: 3 }
|
label: 'Base damage',
|
||||||
],
|
operator: 'base_damage',
|
||||||
profiles: [{
|
display_order: 1,
|
||||||
stat_attribute_id: 'weapon-flat',
|
},
|
||||||
stage_key: 'flat',
|
{
|
||||||
weight: 1.5,
|
stage_key: 'flat',
|
||||||
soft_cap: null,
|
label: 'Flat damage',
|
||||||
hard_cap: 100,
|
operator: 'flat_add',
|
||||||
curve_type: 'none',
|
display_order: 2,
|
||||||
curve_parameter: null,
|
},
|
||||||
weapon_category: 'SMG'
|
{
|
||||||
}],
|
stage_key: 'cycle',
|
||||||
attributes: [{ id: 'weapon-flat', name: 'Flat Weapon Damage', unit: null }]
|
label: 'Firing cycle',
|
||||||
});
|
operator: 'firing_cycle',
|
||||||
expect(lines).toContain('TAKE equipped_weapon.damage AS base_damage');
|
display_order: 3,
|
||||||
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');
|
],
|
||||||
});
|
profiles: [
|
||||||
|
{
|
||||||
|
stat_attribute_id: 'weapon-flat',
|
||||||
|
stage_key: 'flat',
|
||||||
|
weight: 1.5,
|
||||||
|
soft_cap: null,
|
||||||
|
hard_cap: 100,
|
||||||
|
curve_type: 'none',
|
||||||
|
curve_parameter: null,
|
||||||
|
weapon_category: 'SMG',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
attributes: [
|
||||||
|
{ id: 'weapon-flat', name: 'Flat Weapon Damage', unit: null },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(lines).toContain('TAKE equipped_weapon.damage AS base_damage');
|
||||||
|
expect(lines).toContain(
|
||||||
|
'ADD flat_weapon_damage × 1.5 HARD_CAP 100 FOR SMG TO damage',
|
||||||
|
);
|
||||||
|
expect(lines.at(-1)).toBe('RETURN expected_damage_per_second');
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps level scaling in item configuration instead of applying it twice', () => {
|
it('keeps level scaling in item configuration instead of applying it twice', () => {
|
||||||
const lines = formatItemDamageRangeScript();
|
const lines = formatItemDamageRangeScript();
|
||||||
expect(lines).toContain('SCALE configured_damage_range TO equipped_weapon.level');
|
expect(lines).toContain(
|
||||||
expect(lines).toContain('PASS equipped_weapon.damage TO combat_calculation');
|
'SCALE configured_damage_range TO equipped_weapon.level',
|
||||||
expect(lines).not.toContain('SCALE equipped_weapon.damage 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',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,131 +1,147 @@
|
|||||||
export type CalculationScriptStage = {
|
export type CalculationScriptStage = {
|
||||||
stage_key: string;
|
stage_key: string;
|
||||||
label: string;
|
label: string;
|
||||||
operator: string;
|
operator: string;
|
||||||
display_order: number;
|
display_order: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CalculationScriptProfile = {
|
export type CalculationScriptProfile = {
|
||||||
stat_attribute_id: string;
|
stat_attribute_id: string;
|
||||||
stage_key: string;
|
stage_key: string;
|
||||||
weight?: number | null;
|
weight?: number | null;
|
||||||
soft_cap?: number | null;
|
soft_cap?: number | null;
|
||||||
hard_cap?: number | null;
|
hard_cap?: number | null;
|
||||||
curve_type?: string | null;
|
curve_type?: string | null;
|
||||||
curve_parameter?: number | null;
|
curve_parameter?: number | null;
|
||||||
weapon_category?: string | null;
|
weapon_category?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CalculationScriptAttribute = {
|
export type CalculationScriptAttribute = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
unit?: string | null;
|
unit?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CalculationScriptRevision = {
|
export type CalculationScriptRevision = {
|
||||||
version?: number | null;
|
version?: number | null;
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const number = (value: unknown) =>
|
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) =>
|
||||||
.normalize('NFKC')
|
String(value ?? '')
|
||||||
.trim()
|
.normalize('NFKC')
|
||||||
.toLowerCase()
|
.trim()
|
||||||
.replace(/[^a-z0-9]+/g, '_')
|
.toLowerCase()
|
||||||
.replace(/^_+|_+$/g, '') || 'unnamed';
|
.replace(/[^a-z0-9]+/g, '_')
|
||||||
|
.replace(/^_+|_+$/g, '') || 'unnamed';
|
||||||
|
|
||||||
function profileExpression(
|
function profileExpression(
|
||||||
profile: CalculationScriptProfile,
|
profile: CalculationScriptProfile,
|
||||||
attributes: Map<string, CalculationScriptAttribute>
|
attributes: Map<string, CalculationScriptAttribute>,
|
||||||
) {
|
) {
|
||||||
const attribute = attributes.get(profile.stat_attribute_id);
|
const attribute = attributes.get(profile.stat_attribute_id);
|
||||||
const unit = attribute?.unit ? ` ${attribute.unit}` : '';
|
const unit = attribute?.unit ? ` ${attribute.unit}` : '';
|
||||||
const weight = Number(profile.weight ?? 1);
|
const weight = Number(profile.weight ?? 1);
|
||||||
let expression = `${identifier(attribute?.name ?? profile.stat_attribute_id)}${unit}`;
|
let expression = `${identifier(attribute?.name ?? profile.stat_attribute_id)}${unit}`;
|
||||||
if (weight !== 1) expression += ` × ${number(weight)}`;
|
if (weight !== 1) expression += ` × ${number(weight)}`;
|
||||||
if (profile.soft_cap !== null && profile.soft_cap !== undefined) {
|
if (profile.soft_cap !== null && profile.soft_cap !== undefined) {
|
||||||
const curve = profile.curve_type && profile.curve_type !== 'none'
|
const curve =
|
||||||
? ` USING ${String(profile.curve_type).toUpperCase()}(${number(profile.curve_parameter ?? 1)})`
|
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}`;
|
: '';
|
||||||
}
|
expression += ` SOFT_CAP ${number(profile.soft_cap)}${curve}`;
|
||||||
if (profile.hard_cap !== null && profile.hard_cap !== undefined) {
|
}
|
||||||
expression += ` HARD_CAP ${number(profile.hard_cap)}`;
|
if (profile.hard_cap !== null && profile.hard_cap !== undefined) {
|
||||||
}
|
expression += ` HARD_CAP ${number(profile.hard_cap)}`;
|
||||||
if (profile.weapon_category && profile.weapon_category !== '*') {
|
}
|
||||||
expression += ` FOR ${String(profile.weapon_category).toUpperCase()}`;
|
if (profile.weapon_category && profile.weapon_category !== '*') {
|
||||||
}
|
expression += ` FOR ${String(profile.weapon_category).toUpperCase()}`;
|
||||||
return expression;
|
}
|
||||||
|
return expression;
|
||||||
}
|
}
|
||||||
|
|
||||||
const operatorStatement = (operator: string, label: string, sources: string[]) => {
|
const operatorStatement = (
|
||||||
const source = sources.length ? sources.join(', ') : 'no_bound_attributes';
|
operator: string,
|
||||||
switch (operator) {
|
label: string,
|
||||||
case 'base_damage':
|
sources: string[],
|
||||||
return ['TAKE equipped_weapon.damage AS base_damage'];
|
) => {
|
||||||
case 'flat_add':
|
const source = sources.length ? sources.join(', ') : 'no_bound_attributes';
|
||||||
return [`ADD ${source} TO damage`];
|
switch (operator) {
|
||||||
case 'additive_percent':
|
case 'base_damage':
|
||||||
return [`APPLY SUM(${source}) AS additive_percent TO damage`];
|
return ['TAKE equipped_weapon.damage AS base_damage'];
|
||||||
case 'multiplicative_percent':
|
case 'flat_add':
|
||||||
return [`APPLY EACH(${source}) AS multiplicative_percent TO damage`];
|
return [`ADD ${source} TO damage`];
|
||||||
case 'expected_critical':
|
case 'additive_percent':
|
||||||
return [
|
return [`APPLY SUM(${source}) AS additive_percent TO damage`];
|
||||||
`WEIGHT critical_damage BY critical_chance`,
|
case 'multiplicative_percent':
|
||||||
...(sources.length ? [`SOURCE ${source}`] : [])
|
return [`APPLY EACH(${source}) AS multiplicative_percent TO damage`];
|
||||||
];
|
case 'expected_critical':
|
||||||
case 'expected_headshot':
|
return [
|
||||||
return [
|
`WEIGHT critical_damage BY critical_chance`,
|
||||||
`WEIGHT headshot_damage BY headshot_chance`,
|
...(sources.length ? [`SOURCE ${source}`] : []),
|
||||||
...(sources.length ? [`SOURCE ${source}`] : [])
|
];
|
||||||
];
|
case 'expected_headshot':
|
||||||
case 'rate_of_fire':
|
return [
|
||||||
return [`ADJUST equipped_weapon.rpm USING ${source}`];
|
`WEIGHT headshot_damage BY headshot_chance`,
|
||||||
case 'magazine_size':
|
...(sources.length ? [`SOURCE ${source}`] : []),
|
||||||
return [`ADJUST equipped_weapon.magazine USING ${source}`];
|
];
|
||||||
case 'reload_speed':
|
case 'rate_of_fire':
|
||||||
return [`ADJUST equipped_weapon.reload_seconds USING ${source}`];
|
return [`ADJUST equipped_weapon.rpm USING ${source}`];
|
||||||
case 'firing_cycle':
|
case 'magazine_size':
|
||||||
return [
|
return [`ADJUST equipped_weapon.magazine USING ${source}`];
|
||||||
'CALCULATE firing_cycle FROM rpm, magazine, reload_seconds',
|
case 'reload_speed':
|
||||||
'RETURN expected_damage_per_second'
|
return [`ADJUST equipped_weapon.reload_seconds USING ${source}`];
|
||||||
];
|
case 'firing_cycle':
|
||||||
default:
|
return [
|
||||||
return [`APPLY ${identifier(label)} USING ${source}`];
|
'CALCULATE firing_cycle FROM rpm, magazine, reload_seconds',
|
||||||
}
|
'RETURN expected_damage_per_second',
|
||||||
|
];
|
||||||
|
default:
|
||||||
|
return [`APPLY ${identifier(label)} USING ${source}`];
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export function formatWeaponCalculationScript(input: {
|
export function formatWeaponCalculationScript(input: {
|
||||||
revision?: CalculationScriptRevision | null;
|
revision?: CalculationScriptRevision | null;
|
||||||
stages: CalculationScriptStage[];
|
stages: CalculationScriptStage[];
|
||||||
profiles: CalculationScriptProfile[];
|
profiles: CalculationScriptProfile[];
|
||||||
attributes: CalculationScriptAttribute[];
|
attributes: CalculationScriptAttribute[];
|
||||||
}) {
|
}) {
|
||||||
const attributes = new Map(input.attributes.map((attribute) => [String(attribute.id), attribute]));
|
const attributes = new Map(
|
||||||
const lines = [
|
input.attributes.map((attribute) => [String(attribute.id), attribute]),
|
||||||
`CALCULATE expected_weapon_dps${input.revision?.version ? ` WITH rules_r${input.revision.version}` : ''}`
|
);
|
||||||
];
|
const lines = [
|
||||||
for (const stage of [...input.stages].sort(
|
`CALCULATE expected_weapon_dps${input.revision?.version ? ` WITH rules_r${input.revision.version}` : ''}`,
|
||||||
(left, right) => Number(left.display_order) - Number(right.display_order)
|
];
|
||||||
)) {
|
for (const stage of [...input.stages].sort(
|
||||||
const sources = input.profiles
|
(left, right) => Number(left.display_order) - Number(right.display_order),
|
||||||
.filter((profile) => profile.stage_key === stage.stage_key)
|
)) {
|
||||||
.map((profile) => profileExpression(profile, attributes));
|
const sources = input.profiles
|
||||||
lines.push('', `STAGE ${String(stage.label).toUpperCase()}`);
|
.filter((profile) => profile.stage_key === stage.stage_key)
|
||||||
lines.push(...operatorStatement(String(stage.operator), String(stage.label), sources));
|
.map((profile) => profileExpression(profile, attributes));
|
||||||
}
|
lines.push('', `STAGE ${String(stage.label).toUpperCase()}`);
|
||||||
return lines;
|
lines.push(
|
||||||
|
...operatorStatement(
|
||||||
|
String(stage.operator),
|
||||||
|
String(stage.label),
|
||||||
|
sources,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatItemDamageRangeScript() {
|
export function formatItemDamageRangeScript() {
|
||||||
return [
|
return [
|
||||||
'CONFIGURE equipped_weapon',
|
'CONFIGURE equipped_weapon',
|
||||||
'READ configured_damage_range AT level_1',
|
'READ configured_damage_range AT level_1',
|
||||||
'SCALE configured_damage_range TO equipped_weapon.level',
|
'SCALE configured_damage_range TO equipped_weapon.level',
|
||||||
'SELECT equipped_weapon.damage WITHIN scaled_damage_range',
|
'SELECT equipped_weapon.damage WITHIN scaled_damage_range',
|
||||||
'PASS equipped_weapon.damage TO combat_calculation'
|
'PASS equipped_weapon.damage TO combat_calculation',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
+221
-126
@@ -1,141 +1,236 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
calculateCombat,
|
calculateCombat,
|
||||||
expectedWeaponShotDamage,
|
expectedWeaponShotDamage,
|
||||||
type CombatCalculationInput
|
type CombatCalculationInput,
|
||||||
} from './combat.js';
|
} from './combat.js';
|
||||||
|
|
||||||
const base = (overrides: Partial<CombatCalculationInput> = {}): CombatCalculationInput => ({
|
const base = (
|
||||||
weapon: { baseDamage: 100, rpm: 600, magazine: 10, reloadSeconds: 2, headshotMultiplier: 2 },
|
overrides: Partial<CombatCalculationInput> = {},
|
||||||
attributes: { weapon: 20, critChance: 25, critDamage: 50, headshotChance: 0, final: 10 },
|
): CombatCalculationInput => ({
|
||||||
bindings: [
|
weapon: {
|
||||||
{ attributeId: 'weapon', stage: 'weapon_damage', operator: 'add' },
|
baseDamage: 100,
|
||||||
{ attributeId: 'critChance', stage: 'critical_chance', operator: 'add' },
|
rpm: 600,
|
||||||
{ attributeId: 'critDamage', stage: 'critical_damage', operator: 'add' },
|
magazine: 10,
|
||||||
{ attributeId: 'headshotChance', stage: 'headshot_chance', operator: 'add' },
|
reloadSeconds: 2,
|
||||||
{ attributeId: 'final', stage: 'final_damage', operator: 'add' }
|
headshotMultiplier: 2,
|
||||||
],
|
},
|
||||||
assumptions: { mode: 'off', hitRate: 100, headshotRate: 0, firingActivity: 100, encounterSeconds: 20 },
|
attributes: {
|
||||||
...overrides
|
weapon: 20,
|
||||||
|
critChance: 25,
|
||||||
|
critDamage: 50,
|
||||||
|
headshotChance: 0,
|
||||||
|
final: 10,
|
||||||
|
},
|
||||||
|
bindings: [
|
||||||
|
{ attributeId: 'weapon', stage: 'weapon_damage', operator: 'add' },
|
||||||
|
{ attributeId: 'critChance', stage: 'critical_chance', operator: 'add' },
|
||||||
|
{ attributeId: 'critDamage', stage: 'critical_damage', operator: 'add' },
|
||||||
|
{
|
||||||
|
attributeId: 'headshotChance',
|
||||||
|
stage: 'headshot_chance',
|
||||||
|
operator: 'add',
|
||||||
|
},
|
||||||
|
{ attributeId: 'final', stage: 'final_damage', operator: 'add' },
|
||||||
|
],
|
||||||
|
assumptions: {
|
||||||
|
mode: 'off',
|
||||||
|
hitRate: 100,
|
||||||
|
headshotRate: 0,
|
||||||
|
firingActivity: 100,
|
||||||
|
encounterSeconds: 20,
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('calculateCombat', () => {
|
describe('calculateCombat', () => {
|
||||||
it('calculates four hit values and reload-aware DPS', () => {
|
it('calculates four hit values and reload-aware DPS', () => {
|
||||||
const result = calculateCombat(base());
|
const result = calculateCombat(base());
|
||||||
expect(result.status).toBe('OK');
|
expect(result.status).toBe('OK');
|
||||||
expect(result.hitValues).toEqual({ normal: 132, critical: 198, headshot: 264, criticalHeadshot: 396 });
|
expect(result.hitValues).toEqual({
|
||||||
expect(result.burstDps).toBe(1485);
|
normal: 132,
|
||||||
expect(result.sustainedDps).toBeCloseTo(495);
|
critical: 198,
|
||||||
});
|
headshot: 264,
|
||||||
|
criticalHeadshot: 396,
|
||||||
|
});
|
||||||
|
expect(result.burstDps).toBe(1485);
|
||||||
|
expect(result.sustainedDps).toBeCloseTo(495);
|
||||||
|
});
|
||||||
|
|
||||||
it('calculates expected body and headshot damage for timeline events', () => {
|
it('calculates expected body and headshot damage for timeline events', () => {
|
||||||
const input = base({
|
const input = base({
|
||||||
attributes: { critChance: 50, critDamage: 100 },
|
attributes: { critChance: 50, critDamage: 100 },
|
||||||
bindings: [
|
bindings: [
|
||||||
{ attributeId: 'critChance', stage: 'critical_chance', operator: 'add' },
|
{
|
||||||
{ attributeId: 'critDamage', stage: 'critical_damage', operator: 'add' }
|
attributeId: 'critChance',
|
||||||
]
|
stage: 'critical_chance',
|
||||||
});
|
operator: 'add',
|
||||||
expect(expectedWeaponShotDamage(input.weapon, input.attributes, input.bindings)).toBe(150);
|
},
|
||||||
expect(expectedWeaponShotDamage(input.weapon, input.attributes, input.bindings, true)).toBe(300);
|
{
|
||||||
});
|
attributeId: 'critDamage',
|
||||||
|
stage: 'critical_damage',
|
||||||
|
operator: 'add',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
expectedWeaponShotDamage(input.weapon, input.attributes, input.bindings),
|
||||||
|
).toBe(150);
|
||||||
|
expect(
|
||||||
|
expectedWeaponShotDamage(
|
||||||
|
input.weapon,
|
||||||
|
input.attributes,
|
||||||
|
input.bindings,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
).toBe(300);
|
||||||
|
});
|
||||||
|
|
||||||
it('applies weights, hard caps, and a configured curve safely', () => {
|
it('applies weights, hard caps, and a configured curve safely', () => {
|
||||||
const result = calculateCombat(base({
|
const result = calculateCombat(
|
||||||
attributes: { weapon: 100 },
|
base({
|
||||||
bindings: [{
|
attributes: { weapon: 100 },
|
||||||
attributeId: 'weapon',
|
bindings: [
|
||||||
stage: 'weapon_damage',
|
{
|
||||||
operator: 'add',
|
attributeId: 'weapon',
|
||||||
weight: 2,
|
stage: 'weapon_damage',
|
||||||
softCap: 50,
|
operator: 'add',
|
||||||
hardCap: 80,
|
weight: 2,
|
||||||
curveType: 'power',
|
softCap: 50,
|
||||||
curveParameter: 1
|
hardCap: 80,
|
||||||
}]
|
curveType: 'power',
|
||||||
}));
|
curveParameter: 1,
|
||||||
expect(result.hitValues?.normal).toBe(180);
|
},
|
||||||
});
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.hitValues?.normal).toBe(180);
|
||||||
|
});
|
||||||
|
|
||||||
it('applies firing-cycle attributes to burst and sustained DPS', () => {
|
it('applies firing-cycle attributes to burst and sustained DPS', () => {
|
||||||
const result = calculateCombat(base({
|
const result = calculateCombat(
|
||||||
attributes: { rate: 20, magazine: 100, reload: 100 },
|
base({
|
||||||
bindings: [
|
attributes: { rate: 20, magazine: 100, reload: 100 },
|
||||||
{ attributeId: 'rate', stage: 'rate_of_fire', operator: 'add' },
|
bindings: [
|
||||||
{ attributeId: 'magazine', stage: 'magazine_size', operator: 'add' },
|
{ attributeId: 'rate', stage: 'rate_of_fire', operator: 'add' },
|
||||||
{ attributeId: 'reload', stage: 'reload_speed', operator: 'add' }
|
{ attributeId: 'magazine', stage: 'magazine_size', operator: 'add' },
|
||||||
]
|
{ attributeId: 'reload', stage: 'reload_speed', operator: 'add' },
|
||||||
}));
|
],
|
||||||
expect(result.status).toBe('OK');
|
}),
|
||||||
expect(result.burstDps).toBe(1200);
|
);
|
||||||
expect(result.sustainedDps).toBeCloseTo(750);
|
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', () => {
|
it('keeps effects off, applies peak stacks, and honors per-effect overrides', () => {
|
||||||
const stackingEffects = [{
|
const stackingEffects = [
|
||||||
id: 'relentless',
|
{
|
||||||
trigger: { hitsRequired: 9, windowSeconds: 3 },
|
id: 'relentless',
|
||||||
lifecycle: { maxStacks: 10, durationSeconds: 10, stacksPerTrigger: 1 },
|
trigger: { hitsRequired: 9, windowSeconds: 3 },
|
||||||
attributes: { weapon: 3, critDamage: 1 }
|
lifecycle: { maxStacks: 10, durationSeconds: 10, stacksPerTrigger: 1 },
|
||||||
}];
|
attributes: { weapon: 3, critDamage: 1 },
|
||||||
const off = calculateCombat(base({ stackingEffects }));
|
},
|
||||||
const peak = calculateCombat(base({
|
];
|
||||||
stackingEffects,
|
const off = calculateCombat(base({ stackingEffects }));
|
||||||
assumptions: { mode: 'off', perEffect: { relentless: { mode: 'peak' } } }
|
const peak = calculateCombat(
|
||||||
}));
|
base({
|
||||||
expect(off.hitValues?.normal).toBe(132);
|
stackingEffects,
|
||||||
expect(peak.hitValues?.normal).toBeCloseTo(165);
|
assumptions: {
|
||||||
expect(peak.stackSummaries[0].stacksAtEnd).toBe(10);
|
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', () => {
|
it('models encounter stack ramp and refresh with the weapon firing cycle', () => {
|
||||||
const result = calculateCombat(base({
|
const result = calculateCombat(
|
||||||
stackingEffects: [{
|
base({
|
||||||
id: 'relentless',
|
stackingEffects: [
|
||||||
trigger: { hitsRequired: 9, windowSeconds: 3 },
|
{
|
||||||
lifecycle: { maxStacks: 10, durationSeconds: 10, stacksPerTrigger: 1 },
|
id: 'relentless',
|
||||||
attributes: { weapon: 3 }
|
trigger: { hitsRequired: 9, windowSeconds: 3 },
|
||||||
}],
|
lifecycle: {
|
||||||
assumptions: { mode: 'encounter', hitRate: 100, firingActivity: 100, encounterSeconds: 30 }
|
maxStacks: 10,
|
||||||
}));
|
durationSeconds: 10,
|
||||||
expect(result.encounterDps).not.toBeNull();
|
stacksPerTrigger: 1,
|
||||||
expect(result.encounterDps!).toBeGreaterThan(495);
|
},
|
||||||
expect(result.stackSummaries[0].maxStacksReached).toBe(10);
|
attributes: { weapon: 3 },
|
||||||
expect(result.stackSummaries[0].maintained).toBe(true);
|
},
|
||||||
});
|
],
|
||||||
|
assumptions: {
|
||||||
|
mode: 'encounter',
|
||||||
|
hitRate: 100,
|
||||||
|
firingActivity: 100,
|
||||||
|
encounterSeconds: 30,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.encounterDps).not.toBeNull();
|
||||||
|
expect(result.encounterDps!).toBeGreaterThan(495);
|
||||||
|
expect(result.stackSummaries[0].maxStacksReached).toBe(10);
|
||||||
|
expect(result.stackSummaries[0].maintained).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('does not trigger when expected hits cannot meet the trigger window', () => {
|
it('does not trigger when expected hits cannot meet the trigger window', () => {
|
||||||
const result = calculateCombat(base({
|
const result = calculateCombat(
|
||||||
stackingEffects: [{
|
base({
|
||||||
id: 'slow',
|
stackingEffects: [
|
||||||
trigger: { hitsRequired: 9, windowSeconds: 0.5 },
|
{
|
||||||
lifecycle: { maxStacks: 10, durationSeconds: 10 },
|
id: 'slow',
|
||||||
attributes: { weapon: 3 }
|
trigger: { hitsRequired: 9, windowSeconds: 0.5 },
|
||||||
}],
|
lifecycle: { maxStacks: 10, durationSeconds: 10 },
|
||||||
assumptions: { mode: 'encounter', hitRate: 10, firingActivity: 50, encounterSeconds: 20 }
|
attributes: { weapon: 3 },
|
||||||
}));
|
},
|
||||||
expect(result.stackSummaries[0]).toMatchObject({ maxStacksReached: 0, averageStacks: 0, maintained: false });
|
],
|
||||||
});
|
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', () => {
|
it('returns explicit requirements instead of partial results', () => {
|
||||||
const result = calculateCombat(base({
|
const result = calculateCombat(
|
||||||
weapon: { baseDamage: 100, rpm: 0, magazine: 0, reloadSeconds: -1 },
|
base({
|
||||||
assumptions: { mode: 'encounter' },
|
weapon: { baseDamage: 100, rpm: 0, magazine: 0, reloadSeconds: -1 },
|
||||||
stackingEffects: [{
|
assumptions: { mode: 'encounter' },
|
||||||
id: 'need-profile',
|
stackingEffects: [
|
||||||
trigger: { hitsRequired: 1, windowSeconds: 1 },
|
{
|
||||||
lifecycle: { maxStacks: 1, durationSeconds: 1 },
|
id: 'need-profile',
|
||||||
attributes: {}
|
trigger: { hitsRequired: 1, windowSeconds: 1 },
|
||||||
}]
|
lifecycle: { maxStacks: 1, durationSeconds: 1 },
|
||||||
}));
|
attributes: {},
|
||||||
expect(result).toMatchObject({ status: 'INSUFFICIENT_DATA', hitValues: null, burstDps: null });
|
},
|
||||||
expect(result.missing).toEqual(expect.arrayContaining([
|
],
|
||||||
'weapon.rpm',
|
}),
|
||||||
'weapon.magazine',
|
);
|
||||||
'weapon.reloadSeconds',
|
expect(result).toMatchObject({
|
||||||
'assumptions.hitRate',
|
status: 'INSUFFICIENT_DATA',
|
||||||
'assumptions.firingActivity',
|
hitValues: null,
|
||||||
'assumptions.encounterSeconds'
|
burstDps: null,
|
||||||
]));
|
});
|
||||||
});
|
expect(result.missing).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
'weapon.rpm',
|
||||||
|
'weapon.magazine',
|
||||||
|
'weapon.reloadSeconds',
|
||||||
|
'assumptions.hitRate',
|
||||||
|
'assumptions.firingActivity',
|
||||||
|
'assumptions.encounterSeconds',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+525
-390
@@ -1,465 +1,600 @@
|
|||||||
export type CalculationStage =
|
export type CalculationStage =
|
||||||
| 'flat_damage'
|
| 'flat_damage'
|
||||||
| 'weapon_damage'
|
| 'weapon_damage'
|
||||||
| 'critical_chance'
|
| 'critical_chance'
|
||||||
| 'critical_damage'
|
| 'critical_damage'
|
||||||
| 'headshot_chance'
|
| 'headshot_chance'
|
||||||
| 'headshot_damage'
|
| 'headshot_damage'
|
||||||
| 'rate_of_fire'
|
| 'rate_of_fire'
|
||||||
| 'magazine_size'
|
| 'magazine_size'
|
||||||
| 'reload_speed'
|
| 'reload_speed'
|
||||||
| 'final_damage';
|
| 'final_damage';
|
||||||
export type CalculationOperator = 'add' | 'multiply';
|
export type CalculationOperator = 'add' | 'multiply';
|
||||||
export type CurveType = 'none' | 'power' | 'smoothstep';
|
export type CurveType = 'none' | 'power' | 'smoothstep';
|
||||||
export type AttributeBinding = {
|
export type AttributeBinding = {
|
||||||
attributeId: string;
|
attributeId: string;
|
||||||
stage: CalculationStage;
|
stage: CalculationStage;
|
||||||
operator: CalculationOperator;
|
operator: CalculationOperator;
|
||||||
weight?: number;
|
weight?: number;
|
||||||
softCap?: number | null;
|
softCap?: number | null;
|
||||||
hardCap?: number | null;
|
hardCap?: number | null;
|
||||||
curveType?: CurveType;
|
curveType?: CurveType;
|
||||||
curveParameter?: number | null;
|
curveParameter?: number | null;
|
||||||
};
|
};
|
||||||
export type StatValues = Record<string, number>;
|
export type StatValues = Record<string, number>;
|
||||||
export type WeaponCombatInput = {
|
export type WeaponCombatInput = {
|
||||||
baseDamage?: number;
|
baseDamage?: number;
|
||||||
rpm?: number;
|
rpm?: number;
|
||||||
magazine?: number;
|
magazine?: number;
|
||||||
reloadSeconds?: number;
|
reloadSeconds?: number;
|
||||||
headshotMultiplier?: number;
|
headshotMultiplier?: number;
|
||||||
};
|
};
|
||||||
export type WeaponFiringCycle = {
|
export type WeaponFiringCycle = {
|
||||||
rpm: number;
|
rpm: number;
|
||||||
magazine: number;
|
magazine: number;
|
||||||
reloadSeconds: number;
|
reloadSeconds: number;
|
||||||
};
|
};
|
||||||
export type StackingMode = 'off' | 'peak' | 'encounter';
|
export type StackingMode = 'off' | 'peak' | 'encounter';
|
||||||
export type TriggerRule = { hitsRequired: number; windowSeconds: number };
|
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 = {
|
export type StackingEffect = {
|
||||||
id: string;
|
id: string;
|
||||||
trigger: TriggerRule;
|
trigger: TriggerRule;
|
||||||
lifecycle: StackLifecycle;
|
lifecycle: StackLifecycle;
|
||||||
attributes: StatValues;
|
attributes: StatValues;
|
||||||
};
|
};
|
||||||
export type CombatAssumptions = {
|
export type CombatAssumptions = {
|
||||||
mode: StackingMode;
|
mode: StackingMode;
|
||||||
hitRate?: number;
|
hitRate?: number;
|
||||||
headshotRate?: number;
|
headshotRate?: number;
|
||||||
firingActivity?: number;
|
firingActivity?: number;
|
||||||
encounterSeconds?: number;
|
encounterSeconds?: number;
|
||||||
perEffect?: Record<string, Partial<Omit<CombatAssumptions, 'perEffect'>>>;
|
perEffect?: Record<string, Partial<Omit<CombatAssumptions, 'perEffect'>>>;
|
||||||
};
|
};
|
||||||
export type MissingRequirement =
|
export type MissingRequirement =
|
||||||
| 'weapon.baseDamage'
|
| 'weapon.baseDamage'
|
||||||
| 'weapon.rpm'
|
| 'weapon.rpm'
|
||||||
| 'weapon.magazine'
|
| 'weapon.magazine'
|
||||||
| 'weapon.reloadSeconds'
|
| 'weapon.reloadSeconds'
|
||||||
| 'assumptions.hitRate'
|
| 'assumptions.hitRate'
|
||||||
| 'assumptions.firingActivity'
|
| 'assumptions.firingActivity'
|
||||||
| 'assumptions.encounterSeconds'
|
| 'assumptions.encounterSeconds'
|
||||||
| `binding:${string}`;
|
| `binding:${string}`;
|
||||||
export type DamageValues = {
|
export type DamageValues = {
|
||||||
normal: number;
|
normal: number;
|
||||||
critical: number;
|
critical: number;
|
||||||
headshot: number;
|
headshot: number;
|
||||||
criticalHeadshot: number;
|
criticalHeadshot: number;
|
||||||
};
|
};
|
||||||
export type StackSummary = {
|
export type StackSummary = {
|
||||||
effectId: string;
|
effectId: string;
|
||||||
mode: StackingMode;
|
mode: StackingMode;
|
||||||
stacksAtEnd: number;
|
stacksAtEnd: number;
|
||||||
maxStacksReached: number;
|
maxStacksReached: number;
|
||||||
buildUpSeconds: number | null;
|
buildUpSeconds: number | null;
|
||||||
averageStacks: number;
|
averageStacks: number;
|
||||||
maintained: boolean;
|
maintained: boolean;
|
||||||
};
|
};
|
||||||
export type CombatCalculation = {
|
export type CombatCalculation = {
|
||||||
status: 'OK' | 'INSUFFICIENT_DATA';
|
status: 'OK' | 'INSUFFICIENT_DATA';
|
||||||
missing: MissingRequirement[];
|
missing: MissingRequirement[];
|
||||||
hitValues: DamageValues | null;
|
hitValues: DamageValues | null;
|
||||||
burstDps: number | null;
|
burstDps: number | null;
|
||||||
sustainedDps: number | null;
|
sustainedDps: number | null;
|
||||||
encounterDps: number | null;
|
encounterDps: number | null;
|
||||||
stackSummaries: StackSummary[];
|
stackSummaries: StackSummary[];
|
||||||
};
|
};
|
||||||
export type CombatCalculationInput = {
|
export type CombatCalculationInput = {
|
||||||
weapon: WeaponCombatInput;
|
weapon: WeaponCombatInput;
|
||||||
attributes: StatValues;
|
attributes: StatValues;
|
||||||
bindings: AttributeBinding[];
|
bindings: AttributeBinding[];
|
||||||
stageOrder?: CalculationStage[];
|
stageOrder?: CalculationStage[];
|
||||||
stackingEffects?: StackingEffect[];
|
stackingEffects?: StackingEffect[];
|
||||||
assumptions: CombatAssumptions;
|
assumptions: CombatAssumptions;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ResolvedAssumptions = {
|
type ResolvedAssumptions = {
|
||||||
mode: StackingMode;
|
mode: StackingMode;
|
||||||
hitRate: number;
|
hitRate: number;
|
||||||
headshotRate: number;
|
headshotRate: number;
|
||||||
firingActivity: number;
|
firingActivity: number;
|
||||||
encounterSeconds: number;
|
encounterSeconds: number;
|
||||||
};
|
};
|
||||||
type StackState = {
|
type StackState = {
|
||||||
stacks: number;
|
stacks: number;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
maxStacksReached: number;
|
maxStacksReached: number;
|
||||||
buildUpSeconds: number | null;
|
buildUpSeconds: number | null;
|
||||||
};
|
};
|
||||||
type TimedStackEffect = {
|
type TimedStackEffect = {
|
||||||
effect: StackingEffect;
|
effect: StackingEffect;
|
||||||
assumptions: ResolvedAssumptions;
|
assumptions: ResolvedAssumptions;
|
||||||
events: number[];
|
events: number[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const EPSILON = 0.000_001;
|
const EPSILON = 0.000_001;
|
||||||
const finitePositive = (value: unknown) => Number.isFinite(Number(value)) && Number(value) > 0;
|
const finitePositive = (value: unknown) =>
|
||||||
const finiteNonNegative = (value: unknown) => Number.isFinite(Number(value)) && Number(value) >= 0;
|
Number.isFinite(Number(value)) && Number(value) > 0;
|
||||||
const clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value));
|
const finiteNonNegative = (value: unknown) =>
|
||||||
const percent = (value: unknown, fallback = 0) => clamp(Number.isFinite(Number(value)) ? Number(value) : fallback, 0, 100) / 100;
|
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(
|
||||||
const override = base.perEffect?.[effectId] ?? {};
|
base: CombatAssumptions,
|
||||||
return {
|
effectId: string,
|
||||||
mode: override.mode ?? base.mode,
|
): ResolvedAssumptions {
|
||||||
hitRate: percent(override.hitRate ?? base.hitRate, 100),
|
const override = base.perEffect?.[effectId] ?? {};
|
||||||
headshotRate: percent(override.headshotRate ?? base.headshotRate, 0),
|
return {
|
||||||
firingActivity: percent(override.firingActivity ?? base.firingActivity, 100),
|
mode: override.mode ?? base.mode,
|
||||||
encounterSeconds: Number(override.encounterSeconds ?? base.encounterSeconds ?? 0)
|
hitRate: percent(override.hitRate ?? base.hitRate, 100),
|
||||||
};
|
headshotRate: percent(override.headshotRate ?? base.headshotRate, 0),
|
||||||
|
firingActivity: percent(
|
||||||
|
override.firingActivity ?? base.firingActivity,
|
||||||
|
100,
|
||||||
|
),
|
||||||
|
encounterSeconds: Number(
|
||||||
|
override.encounterSeconds ?? base.encounterSeconds ?? 0,
|
||||||
|
),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function transformedValue(raw: number, binding: AttributeBinding) {
|
function transformedValue(raw: number, binding: AttributeBinding) {
|
||||||
const weight = Number.isFinite(binding.weight) ? Number(binding.weight) : 1;
|
const weight = Number.isFinite(binding.weight) ? Number(binding.weight) : 1;
|
||||||
const sign = raw < 0 ? -1 : 1;
|
const sign = raw < 0 ? -1 : 1;
|
||||||
let value = Math.abs(raw * weight);
|
let value = Math.abs(raw * weight);
|
||||||
const softCap = finiteNonNegative(binding.softCap) ? Number(binding.softCap) : null;
|
const softCap = finiteNonNegative(binding.softCap)
|
||||||
const hardCap = finiteNonNegative(binding.hardCap) ? Number(binding.hardCap) : null;
|
? Number(binding.softCap)
|
||||||
if (softCap !== null && value > softCap && binding.curveType && binding.curveType !== 'none') {
|
: null;
|
||||||
const parameter = Math.max(0.01, Number(binding.curveParameter ?? 1));
|
const hardCap = finiteNonNegative(binding.hardCap)
|
||||||
const overflow = value - softCap;
|
? Number(binding.hardCap)
|
||||||
if (binding.curveType === 'power') {
|
: null;
|
||||||
value = softCap + overflow * Math.pow(softCap / Math.max(softCap + overflow, EPSILON), parameter);
|
if (
|
||||||
} else {
|
softCap !== null &&
|
||||||
const ceiling = hardCap ?? Math.max(softCap * 2, softCap + 1);
|
value > softCap &&
|
||||||
const t = clamp(overflow / Math.max(ceiling - softCap, EPSILON), 0, 1);
|
binding.curveType &&
|
||||||
value = softCap + (ceiling - softCap) * (t * t * (3 - 2 * t));
|
binding.curveType !== 'none'
|
||||||
}
|
) {
|
||||||
}
|
const parameter = Math.max(0.01, Number(binding.curveParameter ?? 1));
|
||||||
if (hardCap !== null) value = Math.min(value, hardCap);
|
const overflow = value - softCap;
|
||||||
return value * sign;
|
if (binding.curveType === 'power') {
|
||||||
|
value =
|
||||||
|
softCap +
|
||||||
|
overflow *
|
||||||
|
Math.pow(softCap / Math.max(softCap + overflow, EPSILON), parameter);
|
||||||
|
} else {
|
||||||
|
const ceiling = hardCap ?? Math.max(softCap * 2, softCap + 1);
|
||||||
|
const t = clamp(overflow / Math.max(ceiling - softCap, EPSILON), 0, 1);
|
||||||
|
value = softCap + (ceiling - softCap) * (t * t * (3 - 2 * t));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hardCap !== null) value = Math.min(value, hardCap);
|
||||||
|
return value * sign;
|
||||||
}
|
}
|
||||||
|
|
||||||
function addAttributes(target: StatValues, incoming: StatValues, multiplier = 1) {
|
function addAttributes(
|
||||||
for (const [id, value] of Object.entries(incoming)) {
|
target: StatValues,
|
||||||
if (Number.isFinite(value)) target[id] = (target[id] ?? 0) + value * multiplier;
|
incoming: StatValues,
|
||||||
}
|
multiplier = 1,
|
||||||
|
) {
|
||||||
|
for (const [id, value] of Object.entries(incoming)) {
|
||||||
|
if (Number.isFinite(value))
|
||||||
|
target[id] = (target[id] ?? 0) + value * multiplier;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function requiredData(input: CombatCalculationInput): MissingRequirement[] {
|
function requiredData(input: CombatCalculationInput): MissingRequirement[] {
|
||||||
const missing: MissingRequirement[] = [];
|
const missing: MissingRequirement[] = [];
|
||||||
if (!finitePositive(input.weapon.baseDamage)) missing.push('weapon.baseDamage');
|
if (!finitePositive(input.weapon.baseDamage))
|
||||||
if (!finitePositive(input.weapon.rpm)) missing.push('weapon.rpm');
|
missing.push('weapon.baseDamage');
|
||||||
if (!Number.isInteger(input.weapon.magazine) || Number(input.weapon.magazine) < 1) missing.push('weapon.magazine');
|
if (!finitePositive(input.weapon.rpm)) missing.push('weapon.rpm');
|
||||||
if (!finiteNonNegative(input.weapon.reloadSeconds)) missing.push('weapon.reloadSeconds');
|
if (
|
||||||
const boundIds = new Set(input.bindings.map((binding) => binding.attributeId));
|
!Number.isInteger(input.weapon.magazine) ||
|
||||||
for (const binding of input.bindings) {
|
Number(input.weapon.magazine) < 1
|
||||||
if (!Number.isFinite(input.attributes[binding.attributeId] ?? 0)) missing.push(`binding:${binding.attributeId}`);
|
)
|
||||||
}
|
missing.push('weapon.magazine');
|
||||||
if (input.assumptions.mode === 'encounter') {
|
if (!finiteNonNegative(input.weapon.reloadSeconds))
|
||||||
if (!finitePositive(input.assumptions.hitRate)) missing.push('assumptions.hitRate');
|
missing.push('weapon.reloadSeconds');
|
||||||
if (!finitePositive(input.assumptions.firingActivity)) missing.push('assumptions.firingActivity');
|
const boundIds = new Set(
|
||||||
if (!finitePositive(input.assumptions.encounterSeconds)) missing.push('assumptions.encounterSeconds');
|
input.bindings.map((binding) => binding.attributeId),
|
||||||
}
|
);
|
||||||
for (const effect of input.stackingEffects ?? []) {
|
for (const binding of input.bindings) {
|
||||||
const assumptions = resolveAssumptions(input.assumptions, effect.id);
|
if (!Number.isFinite(input.attributes[binding.attributeId] ?? 0))
|
||||||
if (assumptions.mode === 'encounter') {
|
missing.push(`binding:${binding.attributeId}`);
|
||||||
const override = input.assumptions.perEffect?.[effect.id];
|
}
|
||||||
if (!finitePositive(override?.hitRate ?? input.assumptions.hitRate)) missing.push('assumptions.hitRate');
|
if (input.assumptions.mode === 'encounter') {
|
||||||
if (!finitePositive(override?.firingActivity ?? input.assumptions.firingActivity)) missing.push('assumptions.firingActivity');
|
if (!finitePositive(input.assumptions.hitRate))
|
||||||
if (!finitePositive(override?.encounterSeconds ?? input.assumptions.encounterSeconds)) missing.push('assumptions.encounterSeconds');
|
missing.push('assumptions.hitRate');
|
||||||
}
|
if (!finitePositive(input.assumptions.firingActivity))
|
||||||
if (assumptions.mode !== 'off') for (const id of Object.keys(effect.attributes)) {
|
missing.push('assumptions.firingActivity');
|
||||||
if (!boundIds.has(id)) missing.push(`binding:${id}`);
|
if (!finitePositive(input.assumptions.encounterSeconds))
|
||||||
}
|
missing.push('assumptions.encounterSeconds');
|
||||||
}
|
}
|
||||||
return [...new Set(missing)];
|
for (const effect of input.stackingEffects ?? []) {
|
||||||
|
const assumptions = resolveAssumptions(input.assumptions, effect.id);
|
||||||
|
if (assumptions.mode === 'encounter') {
|
||||||
|
const override = input.assumptions.perEffect?.[effect.id];
|
||||||
|
if (!finitePositive(override?.hitRate ?? input.assumptions.hitRate))
|
||||||
|
missing.push('assumptions.hitRate');
|
||||||
|
if (
|
||||||
|
!finitePositive(
|
||||||
|
override?.firingActivity ?? input.assumptions.firingActivity,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
missing.push('assumptions.firingActivity');
|
||||||
|
if (
|
||||||
|
!finitePositive(
|
||||||
|
override?.encounterSeconds ?? input.assumptions.encounterSeconds,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
missing.push('assumptions.encounterSeconds');
|
||||||
|
}
|
||||||
|
if (assumptions.mode !== 'off')
|
||||||
|
for (const id of Object.keys(effect.attributes)) {
|
||||||
|
if (!boundIds.has(id)) missing.push(`binding:${id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...new Set(missing)];
|
||||||
}
|
}
|
||||||
|
|
||||||
function stageValues(attributes: StatValues, bindings: AttributeBinding[], stage: CalculationStage) {
|
function stageValues(
|
||||||
let additive = 0;
|
attributes: StatValues,
|
||||||
let multiplicative = 1;
|
bindings: AttributeBinding[],
|
||||||
for (const binding of bindings.filter((item) => item.stage === stage)) {
|
stage: CalculationStage,
|
||||||
const value = transformedValue(Number(attributes[binding.attributeId] ?? 0), binding);
|
) {
|
||||||
if (binding.operator === 'add') additive += value;
|
let additive = 0;
|
||||||
else multiplicative *= value;
|
let multiplicative = 1;
|
||||||
}
|
for (const binding of bindings.filter((item) => item.stage === stage)) {
|
||||||
return { additive, multiplicative };
|
const value = transformedValue(
|
||||||
|
Number(attributes[binding.attributeId] ?? 0),
|
||||||
|
binding,
|
||||||
|
);
|
||||||
|
if (binding.operator === 'add') additive += value;
|
||||||
|
else multiplicative *= value;
|
||||||
|
}
|
||||||
|
return { additive, multiplicative };
|
||||||
}
|
}
|
||||||
|
|
||||||
function damageBase(
|
function damageBase(
|
||||||
weapon: Required<WeaponCombatInput>,
|
weapon: Required<WeaponCombatInput>,
|
||||||
attributes: StatValues,
|
attributes: StatValues,
|
||||||
bindings: AttributeBinding[],
|
bindings: AttributeBinding[],
|
||||||
stageOrder?: CalculationStage[]
|
stageOrder?: CalculationStage[],
|
||||||
) {
|
) {
|
||||||
let damage = weapon.baseDamage;
|
let damage = weapon.baseDamage;
|
||||||
const order = stageOrder?.length
|
const order = stageOrder?.length
|
||||||
? stageOrder
|
? stageOrder
|
||||||
: ['flat_damage', 'weapon_damage', 'final_damage'] satisfies CalculationStage[];
|
: ([
|
||||||
for (const stage of order) {
|
'flat_damage',
|
||||||
if (!['flat_damage', 'weapon_damage', 'final_damage'].includes(stage)) continue;
|
'weapon_damage',
|
||||||
const values = stageValues(attributes, bindings, stage);
|
'final_damage',
|
||||||
if (stage === 'flat_damage') damage = (damage + values.additive) * values.multiplicative;
|
] satisfies CalculationStage[]);
|
||||||
else damage *= (1 + values.additive / 100) * values.multiplicative;
|
for (const stage of order) {
|
||||||
}
|
if (!['flat_damage', 'weapon_damage', 'final_damage'].includes(stage))
|
||||||
return damage;
|
continue;
|
||||||
|
const values = stageValues(attributes, bindings, stage);
|
||||||
|
if (stage === 'flat_damage')
|
||||||
|
damage = (damage + values.additive) * values.multiplicative;
|
||||||
|
else damage *= (1 + values.additive / 100) * values.multiplicative;
|
||||||
|
}
|
||||||
|
return damage;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hitValues(
|
function hitValues(
|
||||||
weapon: Required<WeaponCombatInput>,
|
weapon: Required<WeaponCombatInput>,
|
||||||
attributes: StatValues,
|
attributes: StatValues,
|
||||||
bindings: AttributeBinding[],
|
bindings: AttributeBinding[],
|
||||||
stageOrder?: CalculationStage[]
|
stageOrder?: CalculationStage[],
|
||||||
): DamageValues {
|
): DamageValues {
|
||||||
const normal = damageBase(weapon, attributes, bindings, stageOrder);
|
const normal = damageBase(weapon, attributes, bindings, stageOrder);
|
||||||
const critical = stageValues(attributes, bindings, 'critical_damage');
|
const critical = stageValues(attributes, bindings, 'critical_damage');
|
||||||
const headshot = stageValues(attributes, bindings, 'headshot_damage');
|
const headshot = stageValues(attributes, bindings, 'headshot_damage');
|
||||||
const criticalFactor = (1 + critical.additive / 100) * critical.multiplicative;
|
const criticalFactor =
|
||||||
const headshotFactor = weapon.headshotMultiplier * (1 + headshot.additive / 100) * headshot.multiplicative;
|
(1 + critical.additive / 100) * critical.multiplicative;
|
||||||
return {
|
const headshotFactor =
|
||||||
normal,
|
weapon.headshotMultiplier *
|
||||||
critical: normal * criticalFactor,
|
(1 + headshot.additive / 100) *
|
||||||
headshot: normal * headshotFactor,
|
headshot.multiplicative;
|
||||||
criticalHeadshot: normal * criticalFactor * headshotFactor
|
return {
|
||||||
};
|
normal,
|
||||||
|
critical: normal * criticalFactor,
|
||||||
|
headshot: normal * headshotFactor,
|
||||||
|
criticalHeadshot: normal * criticalFactor * headshotFactor,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function dpsFor(
|
function dpsFor(
|
||||||
weapon: Required<WeaponCombatInput>,
|
weapon: Required<WeaponCombatInput>,
|
||||||
attributes: StatValues,
|
attributes: StatValues,
|
||||||
bindings: AttributeBinding[],
|
bindings: AttributeBinding[],
|
||||||
assumptions: ResolvedAssumptions,
|
assumptions: ResolvedAssumptions,
|
||||||
stageOrder?: CalculationStage[]
|
stageOrder?: CalculationStage[],
|
||||||
) {
|
) {
|
||||||
const values = hitValues(weapon, attributes, bindings, stageOrder);
|
const values = hitValues(weapon, attributes, bindings, stageOrder);
|
||||||
const critical = stageValues(attributes, bindings, 'critical_chance');
|
const critical = stageValues(attributes, bindings, 'critical_chance');
|
||||||
const boundCriticalChance = clamp(critical.additive / 100, 0, 1);
|
const boundCriticalChance = clamp(critical.additive / 100, 0, 1);
|
||||||
const configuredHeadshot = stageValues(attributes, bindings, 'headshot_chance');
|
const configuredHeadshot = stageValues(
|
||||||
const boundHeadshotChance = clamp(configuredHeadshot.additive / 100, 0, 1);
|
attributes,
|
||||||
const headshotChance = assumptions.headshotRate || boundHeadshotChance;
|
bindings,
|
||||||
const average = values.normal * (1 - boundCriticalChance) * (1 - headshotChance)
|
'headshot_chance',
|
||||||
+ values.critical * boundCriticalChance * (1 - headshotChance)
|
);
|
||||||
+ values.headshot * (1 - boundCriticalChance) * headshotChance
|
const boundHeadshotChance = clamp(configuredHeadshot.additive / 100, 0, 1);
|
||||||
+ values.criticalHeadshot * boundCriticalChance * headshotChance;
|
const headshotChance = assumptions.headshotRate || boundHeadshotChance;
|
||||||
const { rpm, magazine, reloadSeconds } = resolveWeaponFiringCycle(
|
const average =
|
||||||
weapon,
|
values.normal * (1 - boundCriticalChance) * (1 - headshotChance) +
|
||||||
attributes,
|
values.critical * boundCriticalChance * (1 - headshotChance) +
|
||||||
bindings
|
values.headshot * (1 - boundCriticalChance) * headshotChance +
|
||||||
);
|
values.criticalHeadshot * boundCriticalChance * headshotChance;
|
||||||
const shotsPerSecond = rpm / 60;
|
const { rpm, magazine, reloadSeconds } = resolveWeaponFiringCycle(
|
||||||
return {
|
weapon,
|
||||||
values,
|
attributes,
|
||||||
burst: average * shotsPerSecond,
|
bindings,
|
||||||
sustained: average * magazine / (magazine / shotsPerSecond + reloadSeconds)
|
);
|
||||||
};
|
const shotsPerSecond = rpm / 60;
|
||||||
|
return {
|
||||||
|
values,
|
||||||
|
burst: average * shotsPerSecond,
|
||||||
|
sustained:
|
||||||
|
(average * magazine) / (magazine / shotsPerSecond + reloadSeconds),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function expectedWeaponShotDamage(
|
export function expectedWeaponShotDamage(
|
||||||
weapon: WeaponCombatInput,
|
weapon: WeaponCombatInput,
|
||||||
attributes: StatValues,
|
attributes: StatValues,
|
||||||
bindings: AttributeBinding[],
|
bindings: AttributeBinding[],
|
||||||
headshot = false,
|
headshot = false,
|
||||||
stageOrder?: CalculationStage[]
|
stageOrder?: CalculationStage[],
|
||||||
) {
|
) {
|
||||||
const resolvedWeapon: Required<WeaponCombatInput> = {
|
const resolvedWeapon: Required<WeaponCombatInput> = {
|
||||||
baseDamage: Number(weapon.baseDamage),
|
baseDamage: Number(weapon.baseDamage),
|
||||||
rpm: Number(weapon.rpm),
|
rpm: Number(weapon.rpm),
|
||||||
magazine: Number(weapon.magazine),
|
magazine: Number(weapon.magazine),
|
||||||
reloadSeconds: Number(weapon.reloadSeconds),
|
reloadSeconds: Number(weapon.reloadSeconds),
|
||||||
headshotMultiplier: Number(weapon.headshotMultiplier ?? 2)
|
headshotMultiplier: Number(weapon.headshotMultiplier ?? 2),
|
||||||
};
|
};
|
||||||
const values = hitValues(resolvedWeapon, attributes, bindings, stageOrder);
|
const values = hitValues(resolvedWeapon, attributes, bindings, stageOrder);
|
||||||
const critical = stageValues(attributes, bindings, 'critical_chance');
|
const critical = stageValues(attributes, bindings, 'critical_chance');
|
||||||
const criticalChance = clamp(critical.additive / 100, 0, 1);
|
const criticalChance = clamp(critical.additive / 100, 0, 1);
|
||||||
return headshot
|
return headshot
|
||||||
? values.headshot * (1 - criticalChance) + values.criticalHeadshot * criticalChance
|
? values.headshot * (1 - criticalChance) +
|
||||||
: values.normal * (1 - criticalChance) + values.critical * criticalChance;
|
values.criticalHeadshot * criticalChance
|
||||||
|
: values.normal * (1 - criticalChance) + values.critical * criticalChance;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveWeaponFiringCycle(
|
export function resolveWeaponFiringCycle(
|
||||||
weapon: WeaponCombatInput,
|
weapon: WeaponCombatInput,
|
||||||
attributes: StatValues,
|
attributes: StatValues,
|
||||||
bindings: AttributeBinding[]
|
bindings: AttributeBinding[],
|
||||||
): WeaponFiringCycle {
|
): WeaponFiringCycle {
|
||||||
const rateOfFire = stageValues(attributes, bindings, 'rate_of_fire');
|
const rateOfFire = stageValues(attributes, bindings, 'rate_of_fire');
|
||||||
const magazineSize = stageValues(attributes, bindings, 'magazine_size');
|
const magazineSize = stageValues(attributes, bindings, 'magazine_size');
|
||||||
const reloadSpeed = stageValues(attributes, bindings, 'reload_speed');
|
const reloadSpeed = stageValues(attributes, bindings, 'reload_speed');
|
||||||
return {
|
return {
|
||||||
rpm: Number(weapon.rpm)
|
rpm:
|
||||||
* (1 + rateOfFire.additive / 100)
|
Number(weapon.rpm) *
|
||||||
* rateOfFire.multiplicative,
|
(1 + rateOfFire.additive / 100) *
|
||||||
magazine: Math.max(
|
rateOfFire.multiplicative,
|
||||||
1,
|
magazine: Math.max(
|
||||||
Number(weapon.magazine)
|
1,
|
||||||
* (1 + magazineSize.additive / 100)
|
Number(weapon.magazine) *
|
||||||
* magazineSize.multiplicative
|
(1 + magazineSize.additive / 100) *
|
||||||
),
|
magazineSize.multiplicative,
|
||||||
reloadSeconds: Number(weapon.reloadSeconds) / Math.max(
|
),
|
||||||
EPSILON,
|
reloadSeconds:
|
||||||
(1 + reloadSpeed.additive / 100) * reloadSpeed.multiplicative
|
Number(weapon.reloadSeconds) /
|
||||||
)
|
Math.max(
|
||||||
};
|
EPSILON,
|
||||||
|
(1 + reloadSpeed.additive / 100) * reloadSpeed.multiplicative,
|
||||||
|
),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function triggerEvents(weapon: Required<WeaponCombatInput>, effect: StackingEffect, assumptions: ResolvedAssumptions) {
|
function triggerEvents(
|
||||||
if (assumptions.mode !== 'encounter' || effect.trigger.hitsRequired < 1 || effect.trigger.windowSeconds <= 0) return [];
|
weapon: Required<WeaponCombatInput>,
|
||||||
const shotInterval = (60 / weapon.rpm) / Math.max(assumptions.firingActivity, EPSILON);
|
effect: StackingEffect,
|
||||||
let magazineRemaining = weapon.magazine;
|
assumptions: ResolvedAssumptions,
|
||||||
let expectedHits = 0;
|
) {
|
||||||
let counterStartedAt = 0;
|
if (
|
||||||
const events: number[] = [];
|
assumptions.mode !== 'encounter' ||
|
||||||
for (let time = 0; time <= assumptions.encounterSeconds + EPSILON;) {
|
effect.trigger.hitsRequired < 1 ||
|
||||||
if (magazineRemaining === 0) {
|
effect.trigger.windowSeconds <= 0
|
||||||
time += weapon.reloadSeconds;
|
)
|
||||||
magazineRemaining = weapon.magazine;
|
return [];
|
||||||
if (time - counterStartedAt > effect.trigger.windowSeconds) {
|
const shotInterval =
|
||||||
expectedHits = 0;
|
60 / weapon.rpm / Math.max(assumptions.firingActivity, EPSILON);
|
||||||
counterStartedAt = time;
|
let magazineRemaining = weapon.magazine;
|
||||||
}
|
let expectedHits = 0;
|
||||||
continue;
|
let counterStartedAt = 0;
|
||||||
}
|
const events: number[] = [];
|
||||||
if (expectedHits === 0) counterStartedAt = time;
|
for (let time = 0; time <= assumptions.encounterSeconds + EPSILON;) {
|
||||||
expectedHits += assumptions.hitRate;
|
if (magazineRemaining === 0) {
|
||||||
magazineRemaining -= 1;
|
time += weapon.reloadSeconds;
|
||||||
if (time - counterStartedAt > effect.trigger.windowSeconds) {
|
magazineRemaining = weapon.magazine;
|
||||||
expectedHits = assumptions.hitRate;
|
if (time - counterStartedAt > effect.trigger.windowSeconds) {
|
||||||
counterStartedAt = time;
|
expectedHits = 0;
|
||||||
}
|
counterStartedAt = time;
|
||||||
if (expectedHits + EPSILON >= effect.trigger.hitsRequired) {
|
}
|
||||||
events.push(time);
|
continue;
|
||||||
expectedHits = 0;
|
}
|
||||||
counterStartedAt = time;
|
if (expectedHits === 0) counterStartedAt = time;
|
||||||
}
|
expectedHits += assumptions.hitRate;
|
||||||
time += shotInterval;
|
magazineRemaining -= 1;
|
||||||
}
|
if (time - counterStartedAt > effect.trigger.windowSeconds) {
|
||||||
return events;
|
expectedHits = assumptions.hitRate;
|
||||||
|
counterStartedAt = time;
|
||||||
|
}
|
||||||
|
if (expectedHits + EPSILON >= effect.trigger.hitsRequired) {
|
||||||
|
events.push(time);
|
||||||
|
expectedHits = 0;
|
||||||
|
counterStartedAt = time;
|
||||||
|
}
|
||||||
|
time += shotInterval;
|
||||||
|
}
|
||||||
|
return events;
|
||||||
}
|
}
|
||||||
|
|
||||||
function stateAt(effect: TimedStackEffect, time: number): StackState {
|
function stateAt(effect: TimedStackEffect, time: number): StackState {
|
||||||
if (effect.assumptions.mode === 'off') {
|
if (effect.assumptions.mode === 'off') {
|
||||||
return { stacks: 0, expiresAt: 0, maxStacksReached: 0, buildUpSeconds: null };
|
return {
|
||||||
}
|
stacks: 0,
|
||||||
if (effect.assumptions.mode === 'peak') {
|
expiresAt: 0,
|
||||||
return {
|
maxStacksReached: 0,
|
||||||
stacks: effect.effect.lifecycle.maxStacks,
|
buildUpSeconds: null,
|
||||||
expiresAt: Infinity,
|
};
|
||||||
maxStacksReached: effect.effect.lifecycle.maxStacks,
|
}
|
||||||
buildUpSeconds: 0
|
if (effect.assumptions.mode === 'peak') {
|
||||||
};
|
return {
|
||||||
}
|
stacks: effect.effect.lifecycle.maxStacks,
|
||||||
const state: StackState = { stacks: 0, expiresAt: 0, maxStacksReached: 0, buildUpSeconds: null };
|
expiresAt: Infinity,
|
||||||
for (const eventTime of effect.events) {
|
maxStacksReached: effect.effect.lifecycle.maxStacks,
|
||||||
if (eventTime - time > EPSILON) break;
|
buildUpSeconds: 0,
|
||||||
if (eventTime - state.expiresAt > EPSILON) state.stacks = 0;
|
};
|
||||||
state.stacks = Math.min(
|
}
|
||||||
effect.effect.lifecycle.maxStacks,
|
const state: StackState = {
|
||||||
state.stacks + Math.max(1, effect.effect.lifecycle.stacksPerTrigger ?? 1)
|
stacks: 0,
|
||||||
);
|
expiresAt: 0,
|
||||||
state.expiresAt = eventTime + effect.effect.lifecycle.durationSeconds;
|
maxStacksReached: 0,
|
||||||
state.maxStacksReached = Math.max(state.maxStacksReached, state.stacks);
|
buildUpSeconds: null,
|
||||||
if (state.stacks === effect.effect.lifecycle.maxStacks && state.buildUpSeconds === null) {
|
};
|
||||||
state.buildUpSeconds = eventTime;
|
for (const eventTime of effect.events) {
|
||||||
}
|
if (eventTime - time > EPSILON) break;
|
||||||
}
|
if (eventTime - state.expiresAt > EPSILON) state.stacks = 0;
|
||||||
if (time - state.expiresAt > EPSILON) state.stacks = 0;
|
state.stacks = Math.min(
|
||||||
return state;
|
effect.effect.lifecycle.maxStacks,
|
||||||
|
state.stacks + Math.max(1, effect.effect.lifecycle.stacksPerTrigger ?? 1),
|
||||||
|
);
|
||||||
|
state.expiresAt = eventTime + effect.effect.lifecycle.durationSeconds;
|
||||||
|
state.maxStacksReached = Math.max(state.maxStacksReached, state.stacks);
|
||||||
|
if (
|
||||||
|
state.stacks === effect.effect.lifecycle.maxStacks &&
|
||||||
|
state.buildUpSeconds === null
|
||||||
|
) {
|
||||||
|
state.buildUpSeconds = eventTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (time - state.expiresAt > EPSILON) state.stacks = 0;
|
||||||
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
function stackAttributes(base: StatValues, effects: TimedStackEffect[], time: number) {
|
function stackAttributes(
|
||||||
const result = { ...base };
|
base: StatValues,
|
||||||
for (const effect of effects) addAttributes(result, effect.effect.attributes, stateAt(effect, time).stacks);
|
effects: TimedStackEffect[],
|
||||||
return result;
|
time: number,
|
||||||
|
) {
|
||||||
|
const result = { ...base };
|
||||||
|
for (const effect of effects)
|
||||||
|
addAttributes(
|
||||||
|
result,
|
||||||
|
effect.effect.attributes,
|
||||||
|
stateAt(effect, time).stacks,
|
||||||
|
);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function calculateCombat(input: CombatCalculationInput): CombatCalculation {
|
export function calculateCombat(
|
||||||
const missing = requiredData(input);
|
input: CombatCalculationInput,
|
||||||
if (missing.length) {
|
): CombatCalculation {
|
||||||
return {
|
const missing = requiredData(input);
|
||||||
status: 'INSUFFICIENT_DATA',
|
if (missing.length) {
|
||||||
missing,
|
return {
|
||||||
hitValues: null,
|
status: 'INSUFFICIENT_DATA',
|
||||||
burstDps: null,
|
missing,
|
||||||
sustainedDps: null,
|
hitValues: null,
|
||||||
encounterDps: null,
|
burstDps: null,
|
||||||
stackSummaries: []
|
sustainedDps: null,
|
||||||
};
|
encounterDps: null,
|
||||||
}
|
stackSummaries: [],
|
||||||
const weapon: Required<WeaponCombatInput> = {
|
};
|
||||||
baseDamage: Number(input.weapon.baseDamage),
|
}
|
||||||
rpm: Number(input.weapon.rpm),
|
const weapon: Required<WeaponCombatInput> = {
|
||||||
magazine: Number(input.weapon.magazine),
|
baseDamage: Number(input.weapon.baseDamage),
|
||||||
reloadSeconds: Number(input.weapon.reloadSeconds),
|
rpm: Number(input.weapon.rpm),
|
||||||
headshotMultiplier: Number(input.weapon.headshotMultiplier ?? 2)
|
magazine: Number(input.weapon.magazine),
|
||||||
};
|
reloadSeconds: Number(input.weapon.reloadSeconds),
|
||||||
const effects: TimedStackEffect[] = (input.stackingEffects ?? []).map((effect) => {
|
headshotMultiplier: Number(input.weapon.headshotMultiplier ?? 2),
|
||||||
const assumptions = resolveAssumptions(input.assumptions, effect.id);
|
};
|
||||||
return { effect, assumptions, events: triggerEvents(weapon, effect, assumptions) };
|
const effects: TimedStackEffect[] = (input.stackingEffects ?? []).map(
|
||||||
});
|
(effect) => {
|
||||||
const globalAssumptions = resolveAssumptions(input.assumptions, '');
|
const assumptions = resolveAssumptions(input.assumptions, effect.id);
|
||||||
const finalTime = Math.max(
|
return {
|
||||||
globalAssumptions.encounterSeconds,
|
effect,
|
||||||
...effects.filter((effect) => effect.assumptions.mode === 'encounter').map((effect) => effect.assumptions.encounterSeconds)
|
assumptions,
|
||||||
);
|
events: triggerEvents(weapon, effect, assumptions),
|
||||||
const current = dpsFor(
|
};
|
||||||
weapon,
|
},
|
||||||
stackAttributes(input.attributes, effects, finalTime),
|
);
|
||||||
input.bindings,
|
const globalAssumptions = resolveAssumptions(input.assumptions, '');
|
||||||
globalAssumptions,
|
const finalTime = Math.max(
|
||||||
input.stageOrder
|
globalAssumptions.encounterSeconds,
|
||||||
);
|
...effects
|
||||||
let encounterDps: number | null = null;
|
.filter((effect) => effect.assumptions.mode === 'encounter')
|
||||||
if (effects.some((effect) => effect.assumptions.mode === 'encounter') && finalTime > 0) {
|
.map((effect) => effect.assumptions.encounterSeconds),
|
||||||
const steps = Math.max(30, Math.min(600, Math.ceil(finalTime * 4)));
|
);
|
||||||
let total = 0;
|
const current = dpsFor(
|
||||||
for (let index = 0; index < steps; index += 1) {
|
weapon,
|
||||||
const time = finalTime * (index + 0.5) / steps;
|
stackAttributes(input.attributes, effects, finalTime),
|
||||||
const result = dpsFor(
|
input.bindings,
|
||||||
weapon,
|
globalAssumptions,
|
||||||
stackAttributes(input.attributes, effects, time),
|
input.stageOrder,
|
||||||
input.bindings,
|
);
|
||||||
globalAssumptions,
|
let encounterDps: number | null = null;
|
||||||
input.stageOrder
|
if (
|
||||||
);
|
effects.some((effect) => effect.assumptions.mode === 'encounter') &&
|
||||||
total += result.sustained * globalAssumptions.firingActivity;
|
finalTime > 0
|
||||||
}
|
) {
|
||||||
encounterDps = total / steps;
|
const steps = Math.max(30, Math.min(600, Math.ceil(finalTime * 4)));
|
||||||
}
|
let total = 0;
|
||||||
return {
|
for (let index = 0; index < steps; index += 1) {
|
||||||
status: 'OK',
|
const time = (finalTime * (index + 0.5)) / steps;
|
||||||
missing: [],
|
const result = dpsFor(
|
||||||
hitValues: current.values,
|
weapon,
|
||||||
burstDps: current.burst,
|
stackAttributes(input.attributes, effects, time),
|
||||||
sustainedDps: current.sustained,
|
input.bindings,
|
||||||
encounterDps,
|
globalAssumptions,
|
||||||
stackSummaries: effects.map((item) => {
|
input.stageOrder,
|
||||||
const end = stateAt(item, finalTime);
|
);
|
||||||
const duration = item.assumptions.mode === 'encounter' ? item.assumptions.encounterSeconds : 0;
|
total += result.sustained * globalAssumptions.firingActivity;
|
||||||
const samples = item.assumptions.mode === 'encounter' && duration > 0 ? 100 : 1;
|
}
|
||||||
let sum = 0;
|
encounterDps = total / steps;
|
||||||
for (let index = 0; index < samples; index += 1) {
|
}
|
||||||
sum += stateAt(item, duration * (index + 0.5) / samples).stacks;
|
return {
|
||||||
}
|
status: 'OK',
|
||||||
return {
|
missing: [],
|
||||||
effectId: item.effect.id,
|
hitValues: current.values,
|
||||||
mode: item.assumptions.mode,
|
burstDps: current.burst,
|
||||||
stacksAtEnd: end.stacks,
|
sustainedDps: current.sustained,
|
||||||
maxStacksReached: end.maxStacksReached,
|
encounterDps,
|
||||||
buildUpSeconds: end.buildUpSeconds,
|
stackSummaries: effects.map((item) => {
|
||||||
averageStacks: sum / samples,
|
const end = stateAt(item, finalTime);
|
||||||
maintained: end.maxStacksReached === item.effect.lifecycle.maxStacks
|
const duration =
|
||||||
&& (item.assumptions.mode === 'peak' || end.stacks > 0)
|
item.assumptions.mode === 'encounter'
|
||||||
};
|
? item.assumptions.encounterSeconds
|
||||||
})
|
: 0;
|
||||||
};
|
const samples =
|
||||||
|
item.assumptions.mode === 'encounter' && duration > 0 ? 100 : 1;
|
||||||
|
let sum = 0;
|
||||||
|
for (let index = 0; index < samples; index += 1) {
|
||||||
|
sum += stateAt(item, (duration * (index + 0.5)) / samples).stacks;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
effectId: item.effect.id,
|
||||||
|
mode: item.assumptions.mode,
|
||||||
|
stacksAtEnd: end.stacks,
|
||||||
|
maxStacksReached: end.maxStacksReached,
|
||||||
|
buildUpSeconds: end.buildUpSeconds,
|
||||||
|
averageStacks: sum / samples,
|
||||||
|
maintained:
|
||||||
|
end.maxStacksReached === item.effect.lifecycle.maxStacks &&
|
||||||
|
(item.assumptions.mode === 'peak' || end.stacks > 0),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,413 +1,571 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
TRIGGER_ACTION_REGISTRY,
|
TRIGGER_ACTION_REGISTRY,
|
||||||
evaluateConditionalProgram,
|
evaluateConditionalProgram,
|
||||||
summarizeTrigger,
|
summarizeTrigger,
|
||||||
validateConditionalProgram,
|
validateConditionalProgram,
|
||||||
validateTriggerConfiguration,
|
validateTriggerConfiguration,
|
||||||
type ConditionalEvent,
|
type ConditionalEvent,
|
||||||
type ConditionalProgram,
|
type ConditionalProgram,
|
||||||
type TriggerConfiguration
|
type TriggerConfiguration,
|
||||||
} from './conditional.js';
|
} from './conditional.js';
|
||||||
|
|
||||||
const modifier = {
|
const modifier = {
|
||||||
id: 'weapon-damage',
|
id: 'weapon-damage',
|
||||||
scope: 'equipped_weapon' as const,
|
scope: 'equipped_weapon' as const,
|
||||||
application: 'active_modifier' as const,
|
application: 'active_modifier' as const,
|
||||||
attributeId: 'weapon-damage',
|
attributeId: 'weapon-damage',
|
||||||
value: 3
|
value: 3,
|
||||||
};
|
};
|
||||||
|
|
||||||
const program = (
|
const program = (
|
||||||
trigger: TriggerConfiguration,
|
trigger: TriggerConfiguration,
|
||||||
overrides: Partial<ConditionalProgram> = {}
|
overrides: Partial<ConditionalProgram> = {},
|
||||||
): ConditionalProgram => ({
|
): ConditionalProgram => ({
|
||||||
id: 'test-effect',
|
id: 'test-effect',
|
||||||
trigger,
|
trigger,
|
||||||
lifecycle: {
|
lifecycle: {
|
||||||
cooldownSeconds: 0,
|
cooldownSeconds: 0,
|
||||||
durationSeconds: 10,
|
durationSeconds: 10,
|
||||||
maxStacks: 10,
|
maxStacks: 10,
|
||||||
stacksPerActivation: 1
|
stacksPerActivation: 1,
|
||||||
},
|
},
|
||||||
outcomes: [modifier],
|
outcomes: [modifier],
|
||||||
...overrides
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
const shot = (
|
const shot = (
|
||||||
at: number,
|
at: number,
|
||||||
targetId: string | null,
|
targetId: string | null,
|
||||||
hit = true,
|
hit = true,
|
||||||
headshot = false
|
headshot = false,
|
||||||
): ConditionalEvent => ({ kind: 'shot', at, targetId, hit, headshot });
|
): ConditionalEvent => ({ kind: 'shot', at, targetId, hit, headshot });
|
||||||
|
|
||||||
describe('trigger action registry', () => {
|
describe('trigger action registry', () => {
|
||||||
it('owns every action and validates discriminated parameters', () => {
|
it('owns every action and validates discriminated parameters', () => {
|
||||||
expect(Object.keys(TRIGGER_ACTION_REGISTRY)).toHaveLength(16);
|
expect(Object.keys(TRIGGER_ACTION_REGISTRY)).toHaveLength(16);
|
||||||
expect(validateTriggerConfiguration('same_target_hits_within_window', {
|
expect(
|
||||||
hitsRequired: 9,
|
validateTriggerConfiguration('same_target_hits_within_window', {
|
||||||
windowSeconds: 3
|
hitsRequired: 9,
|
||||||
})).toMatchObject({ ok: true });
|
windowSeconds: 3,
|
||||||
expect(validateTriggerConfiguration('same_target_hits_within_window', {
|
}),
|
||||||
hitsRequired: 0,
|
).toMatchObject({ ok: true });
|
||||||
windowSeconds: -1
|
expect(
|
||||||
})).toEqual({
|
validateTriggerConfiguration('same_target_hits_within_window', {
|
||||||
ok: false,
|
hitsRequired: 0,
|
||||||
errors: ['Hits required must be a positive integer.', 'Window seconds must be greater than 0.']
|
windowSeconds: -1,
|
||||||
});
|
}),
|
||||||
expect(validateTriggerConfiguration('while_firing', { unexpected: true })).toMatchObject({ ok: false });
|
).toEqual({
|
||||||
expect(validateTriggerConfiguration('target_hit_within_distance', { meters: 0 })).toEqual({
|
ok: false,
|
||||||
ok: false,
|
errors: [
|
||||||
errors: ['Distance must be greater than 0.']
|
'Hits required must be a positive integer.',
|
||||||
});
|
'Window seconds must be greater than 0.',
|
||||||
});
|
],
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
validateTriggerConfiguration('while_firing', { unexpected: true }),
|
||||||
|
).toMatchObject({ ok: false });
|
||||||
|
expect(
|
||||||
|
validateTriggerConfiguration('target_hit_within_distance', { meters: 0 }),
|
||||||
|
).toEqual({
|
||||||
|
ok: false,
|
||||||
|
errors: ['Distance must be greater than 0.'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('generates user-facing summaries from typed values', () => {
|
it('generates user-facing summaries from typed values', () => {
|
||||||
expect(summarizeTrigger({
|
expect(
|
||||||
action: 'distinct_enemies_within_window',
|
summarizeTrigger({
|
||||||
params: { enemiesRequired: 4, windowSeconds: 2.5 }
|
action: 'distinct_enemies_within_window',
|
||||||
})).toBe('Hit 4 distinct enemies within 2.5 seconds.');
|
params: { enemiesRequired: 4, windowSeconds: 2.5 },
|
||||||
expect(summarizeTrigger({
|
}),
|
||||||
action: 'continuous_firing_duration',
|
).toBe('Hit 4 distinct enemies within 2.5 seconds.');
|
||||||
params: { seconds: 1 }
|
expect(
|
||||||
})).toBe('Fire continuously for 1 second.');
|
summarizeTrigger({
|
||||||
expect(summarizeTrigger({
|
action: 'continuous_firing_duration',
|
||||||
action: 'target_hit_within_distance',
|
params: { seconds: 1 },
|
||||||
params: { meters: 20 }
|
}),
|
||||||
})).toBe('Hit a target within 20 meters.');
|
).toBe('Fire continuously for 1 second.');
|
||||||
});
|
expect(
|
||||||
|
summarizeTrigger({
|
||||||
|
action: 'target_hit_within_distance',
|
||||||
|
params: { meters: 20 },
|
||||||
|
}),
|
||||||
|
).toBe('Hit a target within 20 meters.');
|
||||||
|
});
|
||||||
|
|
||||||
it('validates unique health bands and their outcome relationships', () => {
|
it('validates unique health bands and their outcome relationships', () => {
|
||||||
const result = validateConditionalProgram(program({
|
const result = validateConditionalProgram(
|
||||||
action: 'health_threshold_bands',
|
program(
|
||||||
params: {
|
{
|
||||||
bands: [
|
action: 'health_threshold_bands',
|
||||||
{ thresholdPercent: 50, outcomeKey: 'low' },
|
params: {
|
||||||
{ thresholdPercent: 20, outcomeKey: 'critical' }
|
bands: [
|
||||||
]
|
{ thresholdPercent: 50, outcomeKey: 'low' },
|
||||||
}
|
{ thresholdPercent: 20, outcomeKey: 'critical' },
|
||||||
}, {
|
],
|
||||||
outcomes: [
|
},
|
||||||
{ ...modifier, id: 'low', outcomeKey: 'low' },
|
},
|
||||||
{ ...modifier, id: 'critical', outcomeKey: 'critical', value: 10 }
|
{
|
||||||
]
|
outcomes: [
|
||||||
}));
|
{ ...modifier, id: 'low', outcomeKey: 'low' },
|
||||||
expect(result).toMatchObject({ ok: true });
|
{ ...modifier, id: 'critical', outcomeKey: 'critical', value: 10 },
|
||||||
});
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(result).toMatchObject({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects negative instant outcomes but permits negative Attribute modifiers', () => {
|
it('rejects negative instant outcomes but permits negative Attribute modifiers', () => {
|
||||||
expect(validateConditionalProgram(program({
|
expect(
|
||||||
action: 'headshot',
|
validateConditionalProgram(
|
||||||
params: {}
|
program(
|
||||||
}, {
|
{
|
||||||
outcomes: [{
|
action: 'headshot',
|
||||||
id: 'damage',
|
params: {},
|
||||||
scope: 'target',
|
},
|
||||||
application: 'direct_damage',
|
{
|
||||||
basis: 'flat',
|
outcomes: [
|
||||||
value: -10
|
{
|
||||||
}]
|
id: 'damage',
|
||||||
}))).toEqual({
|
scope: 'target',
|
||||||
ok: false,
|
application: 'direct_damage',
|
||||||
errors: ['Instant outcome "damage" cannot use a negative value.']
|
basis: 'flat',
|
||||||
});
|
value: -10,
|
||||||
expect(validateConditionalProgram(program({
|
},
|
||||||
action: 'headshot',
|
],
|
||||||
params: {}
|
},
|
||||||
}, {
|
),
|
||||||
outcomes: [{ ...modifier, value: -10 }]
|
),
|
||||||
}))).toMatchObject({ ok: true });
|
).toEqual({
|
||||||
});
|
ok: false,
|
||||||
|
errors: ['Instant outcome "damage" cannot use a negative value.'],
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
validateConditionalProgram(
|
||||||
|
program(
|
||||||
|
{
|
||||||
|
action: 'headshot',
|
||||||
|
params: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
outcomes: [{ ...modifier, value: -10 }],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).toMatchObject({ ok: true });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('evaluateConditionalProgram', () => {
|
describe('evaluateConditionalProgram', () => {
|
||||||
it('counts repeated hits against the same target inside the window', () => {
|
it('counts repeated hits against the same target inside the window', () => {
|
||||||
const result = evaluateConditionalProgram(program({
|
const result = evaluateConditionalProgram(
|
||||||
action: 'same_target_hits_within_window',
|
program({
|
||||||
params: { hitsRequired: 3, windowSeconds: 1 }
|
action: 'same_target_hits_within_window',
|
||||||
}), [
|
params: { hitsRequired: 3, windowSeconds: 1 },
|
||||||
shot(0, 'alpha'),
|
}),
|
||||||
shot(0.2, 'bravo'),
|
[
|
||||||
shot(0.4, 'alpha'),
|
shot(0, 'alpha'),
|
||||||
shot(0.8, 'alpha')
|
shot(0.2, 'bravo'),
|
||||||
], { mode: 'encounter', encounterSeconds: 5 });
|
shot(0.4, 'alpha'),
|
||||||
expect(result).toMatchObject({
|
shot(0.8, 'alpha'),
|
||||||
status: 'OK',
|
],
|
||||||
activationCount: 1,
|
{ mode: 'encounter', encounterSeconds: 5 },
|
||||||
maxStacksReached: 1
|
);
|
||||||
});
|
expect(result).toMatchObject({
|
||||||
expect(result.activations[0]).toMatchObject({ at: 0.8, stacksBefore: 0, stacksAfter: 1 });
|
status: 'OK',
|
||||||
});
|
activationCount: 1,
|
||||||
|
maxStacksReached: 1,
|
||||||
|
});
|
||||||
|
expect(result.activations[0]).toMatchObject({
|
||||||
|
at: 0.8,
|
||||||
|
stacksBefore: 0,
|
||||||
|
stacksAfter: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('counts each distinct enemy only once per window', () => {
|
it('counts each distinct enemy only once per window', () => {
|
||||||
const result = evaluateConditionalProgram(program({
|
const result = evaluateConditionalProgram(
|
||||||
action: 'distinct_enemies_within_window',
|
program({
|
||||||
params: { enemiesRequired: 3, windowSeconds: 2 }
|
action: 'distinct_enemies_within_window',
|
||||||
}), [
|
params: { enemiesRequired: 3, windowSeconds: 2 },
|
||||||
shot(0, 'alpha'),
|
}),
|
||||||
shot(0.2, 'alpha'),
|
[
|
||||||
shot(0.4, 'bravo'),
|
shot(0, 'alpha'),
|
||||||
shot(0.6, 'charlie')
|
shot(0.2, 'alpha'),
|
||||||
], { mode: 'encounter', encounterSeconds: 5 });
|
shot(0.4, 'bravo'),
|
||||||
expect(result.activationCount).toBe(1);
|
shot(0.6, 'charlie'),
|
||||||
expect(result.activations[0].at).toBe(0.6);
|
],
|
||||||
});
|
{ 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', () => {
|
it('resets consecutive hits after a miss or target change', () => {
|
||||||
const result = evaluateConditionalProgram(program({
|
const result = evaluateConditionalProgram(
|
||||||
action: 'consecutive_same_target_hits',
|
program({
|
||||||
params: { hitsRequired: 3 }
|
action: 'consecutive_same_target_hits',
|
||||||
}), [
|
params: { hitsRequired: 3 },
|
||||||
shot(0, 'alpha'),
|
}),
|
||||||
shot(0.1, 'alpha'),
|
[
|
||||||
shot(0.2, null, false),
|
shot(0, 'alpha'),
|
||||||
shot(0.3, 'alpha'),
|
shot(0.1, 'alpha'),
|
||||||
shot(0.4, 'bravo'),
|
shot(0.2, null, false),
|
||||||
shot(0.5, 'bravo'),
|
shot(0.3, 'alpha'),
|
||||||
shot(0.6, 'bravo')
|
shot(0.4, 'bravo'),
|
||||||
], { mode: 'encounter', encounterSeconds: 5 });
|
shot(0.5, 'bravo'),
|
||||||
expect(result.activationCount).toBe(1);
|
shot(0.6, 'bravo'),
|
||||||
expect(result.activations[0].at).toBe(0.6);
|
],
|
||||||
});
|
{ 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', () => {
|
it('starts cooldown on activation and applies the activation after its triggering event', () => {
|
||||||
const result = evaluateConditionalProgram(program({
|
const result = evaluateConditionalProgram(
|
||||||
action: 'headshot',
|
program(
|
||||||
params: {}
|
{
|
||||||
}, {
|
action: 'headshot',
|
||||||
lifecycle: {
|
params: {},
|
||||||
cooldownSeconds: 3,
|
},
|
||||||
durationSeconds: 5,
|
{
|
||||||
maxStacks: 3,
|
lifecycle: {
|
||||||
stacksPerActivation: 1
|
cooldownSeconds: 3,
|
||||||
}
|
durationSeconds: 5,
|
||||||
}), [
|
maxStacks: 3,
|
||||||
shot(1, 'alpha', true, true),
|
stacksPerActivation: 1,
|
||||||
shot(2, 'alpha', true, true),
|
},
|
||||||
shot(4, 'alpha', true, true)
|
},
|
||||||
], { mode: 'encounter', encounterSeconds: 10 });
|
),
|
||||||
expect(result.activations).toHaveLength(2);
|
[
|
||||||
expect(result.activations.map((activation) => activation.at)).toEqual([1, 4]);
|
shot(1, 'alpha', true, true),
|
||||||
expect(result.activations[0].effectiveAfter).toBeGreaterThan(1);
|
shot(2, 'alpha', true, true),
|
||||||
expect(result.activations[1]).toMatchObject({
|
shot(4, 'alpha', true, true),
|
||||||
stacksBefore: 1,
|
],
|
||||||
stacksAfter: 2,
|
{ mode: 'encounter', encounterSeconds: 10 },
|
||||||
expiresAt: 9,
|
);
|
||||||
cooldownUntil: 7
|
expect(result.activations).toHaveLength(2);
|
||||||
});
|
expect(result.activations.map((activation) => activation.at)).toEqual([
|
||||||
});
|
1, 4,
|
||||||
|
]);
|
||||||
|
expect(result.activations[0].effectiveAfter).toBeGreaterThan(1);
|
||||||
|
expect(result.activations[1]).toMatchObject({
|
||||||
|
stacksBefore: 1,
|
||||||
|
stacksAfter: 2,
|
||||||
|
expiresAt: 9,
|
||||||
|
cooldownUntil: 7,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('treats an explicit non-headshot hit as a body shot', () => {
|
it('treats an explicit non-headshot hit as a body shot', () => {
|
||||||
const result = evaluateConditionalProgram(program({
|
const result = evaluateConditionalProgram(
|
||||||
action: 'body_shot',
|
program({
|
||||||
params: {}
|
action: 'body_shot',
|
||||||
}), [
|
params: {},
|
||||||
shot(1, 'alpha', true, false),
|
}),
|
||||||
shot(2, 'alpha', true, true),
|
[
|
||||||
shot(3, 'alpha', false, false)
|
shot(1, 'alpha', true, false),
|
||||||
], { mode: 'encounter', encounterSeconds: 5 });
|
shot(2, 'alpha', true, true),
|
||||||
expect(result.activations.map((activation) => activation.at)).toEqual([1]);
|
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', () => {
|
it('refreshes one shared duration and clears every stack on expiry', () => {
|
||||||
const result = evaluateConditionalProgram(program({
|
const result = evaluateConditionalProgram(
|
||||||
action: 'damage_taken',
|
program(
|
||||||
params: {}
|
{
|
||||||
}, {
|
action: 'damage_taken',
|
||||||
lifecycle: {
|
params: {},
|
||||||
cooldownSeconds: 0,
|
},
|
||||||
durationSeconds: 3,
|
{
|
||||||
maxStacks: 3,
|
lifecycle: {
|
||||||
stacksPerActivation: 1
|
cooldownSeconds: 0,
|
||||||
}
|
durationSeconds: 3,
|
||||||
}), [
|
maxStacks: 3,
|
||||||
{ kind: 'damage_taken', at: 0 },
|
stacksPerActivation: 1,
|
||||||
{ kind: 'damage_taken', at: 1 },
|
},
|
||||||
{ kind: 'damage_taken', at: 5 }
|
},
|
||||||
], { mode: 'encounter', encounterSeconds: 10 });
|
),
|
||||||
expect(result.activations.map(({ stacksBefore, stacksAfter }) => ({ stacksBefore, stacksAfter }))).toEqual([
|
[
|
||||||
{ stacksBefore: 0, stacksAfter: 1 },
|
{ kind: 'damage_taken', at: 0 },
|
||||||
{ stacksBefore: 1, stacksAfter: 2 },
|
{ kind: 'damage_taken', at: 1 },
|
||||||
{ stacksBefore: 0, stacksAfter: 1 }
|
{ kind: 'damage_taken', at: 5 },
|
||||||
]);
|
],
|
||||||
expect(result.intervals).toEqual([
|
{ mode: 'encounter', encounterSeconds: 10 },
|
||||||
{ start: 0.000001, end: 1.000001, stacks: 1, outcomeKey: null },
|
);
|
||||||
{ start: 1.000001, end: 4, stacks: 2, outcomeKey: null },
|
expect(
|
||||||
{ start: 5.000001, end: 8, stacks: 1, outcomeKey: null }
|
result.activations.map(({ stacksBefore, stacksAfter }) => ({
|
||||||
]);
|
stacksBefore,
|
||||||
});
|
stacksAfter,
|
||||||
|
})),
|
||||||
|
).toEqual([
|
||||||
|
{ stacksBefore: 0, stacksAfter: 1 },
|
||||||
|
{ stacksBefore: 1, stacksAfter: 2 },
|
||||||
|
{ stacksBefore: 0, stacksAfter: 1 },
|
||||||
|
]);
|
||||||
|
expect(result.intervals).toEqual([
|
||||||
|
{ start: 0.000001, end: 1.000001, stacks: 1, outcomeKey: null },
|
||||||
|
{ start: 1.000001, end: 4, stacks: 2, outcomeKey: null },
|
||||||
|
{ start: 5.000001, end: 8, stacks: 1, outcomeKey: null },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('resets firing progress on reload without removing an active buff', () => {
|
it('resets firing progress on reload without removing an active buff', () => {
|
||||||
const result = evaluateConditionalProgram(program({
|
const result = evaluateConditionalProgram(
|
||||||
action: 'continuous_firing_duration',
|
program(
|
||||||
params: { seconds: 2 }
|
{
|
||||||
}, {
|
action: 'continuous_firing_duration',
|
||||||
lifecycle: {
|
params: { seconds: 2 },
|
||||||
cooldownSeconds: 0,
|
},
|
||||||
durationSeconds: 10,
|
{
|
||||||
maxStacks: 4,
|
lifecycle: {
|
||||||
stacksPerActivation: 1
|
cooldownSeconds: 0,
|
||||||
}
|
durationSeconds: 10,
|
||||||
}), [
|
maxStacks: 4,
|
||||||
{ kind: 'firing_started', at: 0 },
|
stacksPerActivation: 1,
|
||||||
shot(0.5, 'alpha'),
|
},
|
||||||
{ kind: 'reload_started', at: 3 },
|
},
|
||||||
{ kind: 'reload_completed', at: 4 },
|
),
|
||||||
{ kind: 'firing_started', at: 4 },
|
[
|
||||||
{ kind: 'firing_stopped', at: 7 }
|
{ kind: 'firing_started', at: 0 },
|
||||||
], { mode: 'encounter', encounterSeconds: 12 });
|
shot(0.5, 'alpha'),
|
||||||
expect(result.activations.map((activation) => activation.at)).toEqual([2, 6]);
|
{ kind: 'reload_started', at: 3 },
|
||||||
expect(result.activations[1]).toMatchObject({ stacksBefore: 1, stacksAfter: 2, expiresAt: 16 });
|
{ kind: 'reload_completed', at: 4 },
|
||||||
expect(result.uptimePercent).toBeCloseTo((10 / 12) * 100);
|
{ kind: 'firing_started', at: 4 },
|
||||||
});
|
{ kind: 'firing_stopped', at: 7 },
|
||||||
|
],
|
||||||
|
{ mode: 'encounter', encounterSeconds: 12 },
|
||||||
|
);
|
||||||
|
expect(result.activations.map((activation) => activation.at)).toEqual([
|
||||||
|
2, 6,
|
||||||
|
]);
|
||||||
|
expect(result.activations[1]).toMatchObject({
|
||||||
|
stacksBefore: 1,
|
||||||
|
stacksAfter: 2,
|
||||||
|
expiresAt: 16,
|
||||||
|
});
|
||||||
|
expect(result.uptimePercent).toBeCloseTo((10 / 12) * 100);
|
||||||
|
});
|
||||||
|
|
||||||
it('activates continuous firing only once in each uninterrupted segment', () => {
|
it('activates continuous firing only once in each uninterrupted segment', () => {
|
||||||
const result = evaluateConditionalProgram(program({
|
const result = evaluateConditionalProgram(
|
||||||
action: 'continuous_firing_duration',
|
program({
|
||||||
params: { seconds: 2 }
|
action: 'continuous_firing_duration',
|
||||||
}), [
|
params: { seconds: 2 },
|
||||||
{ kind: 'firing_started', at: 0 },
|
}),
|
||||||
{ kind: 'firing_stopped', at: 7 }
|
[
|
||||||
], { mode: 'encounter', encounterSeconds: 10 });
|
{ kind: 'firing_started', at: 0 },
|
||||||
expect(result.activations.map((activation) => activation.at)).toEqual([2]);
|
{ 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', () => {
|
it('uses only the strongest matching health band', () => {
|
||||||
const result = evaluateConditionalProgram(program({
|
const result = evaluateConditionalProgram(
|
||||||
action: 'health_threshold_bands',
|
program(
|
||||||
params: {
|
{
|
||||||
bands: [
|
action: 'health_threshold_bands',
|
||||||
{ thresholdPercent: 50, outcomeKey: 'low' },
|
params: {
|
||||||
{ thresholdPercent: 20, outcomeKey: 'critical' }
|
bands: [
|
||||||
]
|
{ thresholdPercent: 50, outcomeKey: 'low' },
|
||||||
}
|
{ thresholdPercent: 20, outcomeKey: 'critical' },
|
||||||
}, {
|
],
|
||||||
lifecycle: { cooldownSeconds: 0, durationSeconds: 0, maxStacks: 1, stacksPerActivation: 1 },
|
},
|
||||||
outcomes: [
|
},
|
||||||
{ ...modifier, id: 'low', outcomeKey: 'low', value: 5 },
|
{
|
||||||
{ ...modifier, id: 'critical', outcomeKey: 'critical', value: 10 }
|
lifecycle: {
|
||||||
]
|
cooldownSeconds: 0,
|
||||||
}), [], {
|
durationSeconds: 0,
|
||||||
mode: 'encounter',
|
maxStacks: 1,
|
||||||
encounterSeconds: 20,
|
stacksPerActivation: 1,
|
||||||
healthPercent: 15
|
},
|
||||||
});
|
outcomes: [
|
||||||
expect(result.selectedOutcomeKey).toBe('critical');
|
{ ...modifier, id: 'low', outcomeKey: 'low', value: 5 },
|
||||||
expect(result.intervals).toEqual([{ start: 0, end: 20, stacks: 1, outcomeKey: 'critical' }]);
|
{ ...modifier, id: 'critical', outcomeKey: 'critical', value: 10 },
|
||||||
});
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
{
|
||||||
|
mode: 'encounter',
|
||||||
|
encounterSeconds: 20,
|
||||||
|
healthPercent: 15,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(result.selectedOutcomeKey).toBe('critical');
|
||||||
|
expect(result.intervals).toEqual([
|
||||||
|
{ start: 0, end: 20, stacks: 1, outcomeKey: 'critical' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('allows a direct proc-rate override and reports per-stack instant outcomes', () => {
|
it('allows a direct proc-rate override and reports per-stack instant outcomes', () => {
|
||||||
const result = evaluateConditionalProgram(program({
|
const result = evaluateConditionalProgram(
|
||||||
action: 'weapon_kill',
|
program(
|
||||||
params: {}
|
{
|
||||||
}, {
|
action: 'weapon_kill',
|
||||||
lifecycle: { cooldownSeconds: 0, durationSeconds: 20, maxStacks: 3, stacksPerActivation: 1 },
|
params: {},
|
||||||
outcomes: [{
|
},
|
||||||
id: 'blast',
|
{
|
||||||
scope: 'target',
|
lifecycle: {
|
||||||
application: 'direct_damage',
|
cooldownSeconds: 0,
|
||||||
basis: 'weapon_base_percent',
|
durationSeconds: 20,
|
||||||
value: 25
|
maxStacks: 3,
|
||||||
}]
|
stacksPerActivation: 1,
|
||||||
}), [], {
|
},
|
||||||
mode: 'encounter',
|
outcomes: [
|
||||||
encounterSeconds: 30,
|
{
|
||||||
directProcsPerMinute: 6
|
id: 'blast',
|
||||||
});
|
scope: 'target',
|
||||||
expect(result.activations.map((activation) => activation.at)).toEqual([10, 20, 30]);
|
application: 'direct_damage',
|
||||||
expect(result.outcomeEvents.map((event) => event.value)).toEqual([25, 50, 75]);
|
basis: 'weapon_base_percent',
|
||||||
});
|
value: 25,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
{
|
||||||
|
mode: 'encounter',
|
||||||
|
encounterSeconds: 30,
|
||||||
|
directProcsPerMinute: 6,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(result.activations.map((activation) => activation.at)).toEqual([
|
||||||
|
10, 20, 30,
|
||||||
|
]);
|
||||||
|
expect(result.outcomeEvents.map((event) => event.value)).toEqual([
|
||||||
|
25, 50, 75,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('uses real trigger events when the whole-program activation override is zero', () => {
|
it('uses real trigger events when the whole-program activation override is zero', () => {
|
||||||
const result = evaluateConditionalProgram(program({
|
const result = evaluateConditionalProgram(
|
||||||
action: 'weapon_kill',
|
program({
|
||||||
params: {}
|
action: 'weapon_kill',
|
||||||
}), [
|
params: {},
|
||||||
{ kind: 'enemy_killed', at: 4 }
|
}),
|
||||||
], {
|
[{ kind: 'enemy_killed', at: 4 }],
|
||||||
mode: 'encounter',
|
{
|
||||||
encounterSeconds: 30,
|
mode: 'encounter',
|
||||||
directProcsPerMinute: 0
|
encounterSeconds: 30,
|
||||||
});
|
directProcsPerMinute: 0,
|
||||||
expect(result.activations.map((activation) => activation.at)).toEqual([4]);
|
},
|
||||||
});
|
);
|
||||||
|
expect(result.activations.map((activation) => activation.at)).toEqual([4]);
|
||||||
|
});
|
||||||
|
|
||||||
it('models headshot kills from enemy-killed timeline events', () => {
|
it('models headshot kills from enemy-killed timeline events', () => {
|
||||||
const result = evaluateConditionalProgram(program({
|
const result = evaluateConditionalProgram(
|
||||||
action: 'headshot_kill',
|
program({
|
||||||
params: {}
|
action: 'headshot_kill',
|
||||||
}), [
|
params: {},
|
||||||
{ kind: 'enemy_killed', at: 2, headshot: false },
|
}),
|
||||||
{ kind: 'enemy_killed', at: 7, headshot: true }
|
[
|
||||||
], { mode: 'encounter', encounterSeconds: 15 });
|
{ kind: 'enemy_killed', at: 2, headshot: false },
|
||||||
expect(result.activations.map((activation) => activation.at)).toEqual([7]);
|
{ 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', () => {
|
it('activates on successful target hits while the target is within the configured distance', () => {
|
||||||
const inRange = evaluateConditionalProgram(program({
|
const inRange = evaluateConditionalProgram(
|
||||||
action: 'target_hit_within_distance',
|
program({
|
||||||
params: { meters: 20 }
|
action: 'target_hit_within_distance',
|
||||||
}), [
|
params: { meters: 20 },
|
||||||
shot(1, 'alpha', false),
|
}),
|
||||||
shot(2, null),
|
[
|
||||||
shot(3, 'alpha'),
|
shot(1, 'alpha', false),
|
||||||
shot(4, 'bravo')
|
shot(2, null),
|
||||||
], {
|
shot(3, 'alpha'),
|
||||||
mode: 'encounter',
|
shot(4, 'bravo'),
|
||||||
encounterSeconds: 10,
|
],
|
||||||
distanceMeters: 20
|
{
|
||||||
});
|
mode: 'encounter',
|
||||||
expect(inRange.activations.map((activation) => activation.at)).toEqual([3, 4]);
|
encounterSeconds: 10,
|
||||||
|
distanceMeters: 20,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(inRange.activations.map((activation) => activation.at)).toEqual([
|
||||||
|
3, 4,
|
||||||
|
]);
|
||||||
|
|
||||||
const outOfRange = evaluateConditionalProgram(program({
|
const outOfRange = evaluateConditionalProgram(
|
||||||
action: 'target_hit_within_distance',
|
program({
|
||||||
params: { meters: 20 }
|
action: 'target_hit_within_distance',
|
||||||
}), [shot(3, 'alpha')], {
|
params: { meters: 20 },
|
||||||
mode: 'encounter',
|
}),
|
||||||
encounterSeconds: 10,
|
[shot(3, 'alpha')],
|
||||||
distanceMeters: 21
|
{
|
||||||
});
|
mode: 'encounter',
|
||||||
expect(outOfRange.activationCount).toBe(0);
|
encounterSeconds: 10,
|
||||||
});
|
distanceMeters: 21,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(outOfRange.activationCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('requires a target-distance assumption for distance-limited hit events', () => {
|
it('requires a target-distance assumption for distance-limited hit events', () => {
|
||||||
const result = evaluateConditionalProgram(program({
|
const result = evaluateConditionalProgram(
|
||||||
action: 'target_hit_within_distance',
|
program({
|
||||||
params: { meters: 20 }
|
action: 'target_hit_within_distance',
|
||||||
}), [shot(3, 'alpha')], {
|
params: { meters: 20 },
|
||||||
mode: 'encounter',
|
}),
|
||||||
encounterSeconds: 10
|
[shot(3, 'alpha')],
|
||||||
});
|
{
|
||||||
expect(result.activationCount).toBe(0);
|
mode: 'encounter',
|
||||||
});
|
encounterSeconds: 10,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(result.activationCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps target proximity as a persistent condition rather than a hit event', () => {
|
it('keeps target proximity as a persistent condition rather than a hit event', () => {
|
||||||
const result = evaluateConditionalProgram(program({
|
const result = evaluateConditionalProgram(
|
||||||
action: 'within_distance',
|
program({
|
||||||
params: { meters: 20 }
|
action: 'within_distance',
|
||||||
}), [], {
|
params: { meters: 20 },
|
||||||
mode: 'encounter',
|
}),
|
||||||
encounterSeconds: 10,
|
[],
|
||||||
distanceMeters: 20
|
{
|
||||||
});
|
mode: 'encounter',
|
||||||
expect(result.activationCount).toBe(1);
|
encounterSeconds: 10,
|
||||||
expect(result.intervals).toEqual([{ start: 0, end: 10, stacks: 10, outcomeKey: null }]);
|
distanceMeters: 20,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
expect(result.activationCount).toBe(1);
|
||||||
|
expect(result.intervals).toEqual([
|
||||||
|
{ start: 0, end: 10, stacks: 10, outcomeKey: null },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps while-firing effects active only inside firing segments', () => {
|
it('keeps while-firing effects active only inside firing segments', () => {
|
||||||
const result = evaluateConditionalProgram(program({
|
const result = evaluateConditionalProgram(
|
||||||
action: 'while_firing',
|
program({
|
||||||
params: {}
|
action: 'while_firing',
|
||||||
}), [
|
params: {},
|
||||||
{ kind: 'firing_started', at: 1 },
|
}),
|
||||||
{ kind: 'firing_stopped', at: 3 },
|
[
|
||||||
{ kind: 'firing_started', at: 5 },
|
{ kind: 'firing_started', at: 1 },
|
||||||
{ kind: 'reload_started', at: 8 }
|
{ kind: 'firing_stopped', at: 3 },
|
||||||
], { mode: 'encounter', encounterSeconds: 10 });
|
{ kind: 'firing_started', at: 5 },
|
||||||
expect(result.intervals).toEqual([
|
{ kind: 'reload_started', at: 8 },
|
||||||
{ start: 1, end: 3, stacks: 10, outcomeKey: null },
|
],
|
||||||
{ start: 5, end: 8, stacks: 10, outcomeKey: null }
|
{ mode: 'encounter', encounterSeconds: 10 },
|
||||||
]);
|
);
|
||||||
expect(result.uptimePercent).toBe(50);
|
expect(result.intervals).toEqual([
|
||||||
});
|
{ start: 1, end: 3, stacks: 10, outcomeKey: null },
|
||||||
|
{ start: 5, end: 8, stacks: 10, outcomeKey: null },
|
||||||
|
]);
|
||||||
|
expect(result.uptimePercent).toBe(50);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+1004
-816
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,186 +1,258 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
TRIGGER_ACTION_KEYS,
|
TRIGGER_ACTION_KEYS,
|
||||||
validateTriggerConfiguration,
|
validateTriggerConfiguration,
|
||||||
type TriggerActionKey,
|
type TriggerActionKey,
|
||||||
type TriggerConfiguration
|
type TriggerConfiguration,
|
||||||
} from './conditional.js';
|
} from './conditional.js';
|
||||||
import {
|
import {
|
||||||
createMechanicRecipeForTrigger,
|
createMechanicRecipeForTrigger,
|
||||||
MECHANIC_RECIPE_REGISTRY,
|
MECHANIC_RECIPE_REGISTRY,
|
||||||
type MechanicRecipeSeed
|
type MechanicRecipeSeed,
|
||||||
} from './mechanic-recipes.js';
|
} from './mechanic-recipes.js';
|
||||||
import { validateMechanicProgram } from './mechanic.js';
|
import { validateMechanicProgram } from './mechanic.js';
|
||||||
|
|
||||||
const paramsByAction: Record<TriggerActionKey, unknown> = {
|
const paramsByAction: Record<TriggerActionKey, unknown> = {
|
||||||
same_target_hits_within_window: { hitsRequired: 3, windowSeconds: 2 },
|
same_target_hits_within_window: { hitsRequired: 3, windowSeconds: 2 },
|
||||||
distinct_enemies_within_window: { enemiesRequired: 3, windowSeconds: 4 },
|
distinct_enemies_within_window: { enemiesRequired: 3, windowSeconds: 4 },
|
||||||
consecutive_same_target_hits: { hitsRequired: 4 },
|
consecutive_same_target_hits: { hitsRequired: 4 },
|
||||||
continuous_firing_duration: { seconds: 2.5 },
|
continuous_firing_duration: { seconds: 2.5 },
|
||||||
while_firing: {},
|
while_firing: {},
|
||||||
health_threshold_bands: {
|
health_threshold_bands: {
|
||||||
bands: [
|
bands: [
|
||||||
{ thresholdPercent: 25, outcomeKey: 'critical' },
|
{ thresholdPercent: 25, outcomeKey: 'critical' },
|
||||||
{ thresholdPercent: 50, outcomeKey: 'damaged' }
|
{ thresholdPercent: 50, outcomeKey: 'damaged' },
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
headshot: {},
|
headshot: {},
|
||||||
body_shot: {},
|
body_shot: {},
|
||||||
headshot_kill: {},
|
headshot_kill: {},
|
||||||
weapon_kill: {},
|
weapon_kill: {},
|
||||||
target_hit_within_distance: { meters: 15 },
|
target_hit_within_distance: { meters: 15 },
|
||||||
damage_taken: {},
|
damage_taken: {},
|
||||||
weapon_swap: {},
|
weapon_swap: {},
|
||||||
reload: {},
|
reload: {},
|
||||||
within_distance: { meters: 10 },
|
within_distance: { meters: 10 },
|
||||||
extra_health_active: {}
|
extra_health_active: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
function triggerFor(action: TriggerActionKey): TriggerConfiguration {
|
function triggerFor(action: TriggerActionKey): TriggerConfiguration {
|
||||||
const trigger = validateTriggerConfiguration(action, paramsByAction[action]);
|
const trigger = validateTriggerConfiguration(action, paramsByAction[action]);
|
||||||
if (!trigger.ok) throw new Error(trigger.errors.join(' '));
|
if (!trigger.ok) throw new Error(trigger.errors.join(' '));
|
||||||
return trigger.value;
|
return trigger.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function seedFor(action: TriggerActionKey): MechanicRecipeSeed {
|
function seedFor(action: TriggerActionKey): MechanicRecipeSeed {
|
||||||
const trigger = triggerFor(action);
|
const trigger = triggerFor(action);
|
||||||
const result = createMechanicRecipeForTrigger(trigger);
|
const result = createMechanicRecipeForTrigger(trigger);
|
||||||
if (result.status !== 'supported') throw new Error(result.reason);
|
if (result.status !== 'supported') throw new Error(result.reason);
|
||||||
return result.seed;
|
return result.seed;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('mechanic recipe registry', () => {
|
describe('mechanic recipe registry', () => {
|
||||||
it('covers every trigger action with a supported seed or an explicit reason', () => {
|
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(
|
||||||
for (const action of TRIGGER_ACTION_KEYS) {
|
[...TRIGGER_ACTION_KEYS].sort(),
|
||||||
const result = createMechanicRecipeForTrigger(triggerFor(action));
|
);
|
||||||
expect(['supported', 'unsupported']).toContain(result.status);
|
for (const action of TRIGGER_ACTION_KEYS) {
|
||||||
if (result.status === 'unsupported') expect(result.reason.trim()).not.toBe('');
|
const result = createMechanicRecipeForTrigger(triggerFor(action));
|
||||||
else expect(result.seed.clauses.every((clause) => clause.recipeKey === action)).toBe(true);
|
expect(['supported', 'unsupported']).toContain(result.status);
|
||||||
}
|
if (result.status === 'unsupported')
|
||||||
});
|
expect(result.reason.trim()).not.toBe('');
|
||||||
|
else
|
||||||
|
expect(
|
||||||
|
result.seed.clauses.every((clause) => clause.recipeKey === action),
|
||||||
|
).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('produces a valid editable program for every supported recipe without hidden placeholders', () => {
|
it('produces a valid editable program for every supported recipe without hidden placeholders', () => {
|
||||||
for (const action of TRIGGER_ACTION_KEYS) {
|
for (const action of TRIGGER_ACTION_KEYS) {
|
||||||
const seed = seedFor(action);
|
const seed = seedFor(action);
|
||||||
const validation = validateMechanicProgram({
|
const validation = validateMechanicProgram({
|
||||||
id: `recipe-${action}`,
|
id: `recipe-${action}`,
|
||||||
name: `Recipe ${action}`,
|
name: `Recipe ${action}`,
|
||||||
...seed
|
...seed,
|
||||||
});
|
});
|
||||||
expect(validation, action).toMatchObject({ ok: true });
|
expect(validation, action).toMatchObject({ ok: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('maps hit-family actions to same-target, distinct, and consecutive reducers', () => {
|
it('maps hit-family actions to same-target, distinct, and consecutive reducers', () => {
|
||||||
expect(seedFor('same_target_hits_within_window').clauses[0]).toMatchObject({
|
expect(seedFor('same_target_hits_within_window').clauses[0]).toMatchObject({
|
||||||
when: 'weapon_hit',
|
when: 'weapon_hit',
|
||||||
if: { kind: 'exists', value: { source: 'event', path: 'targetId' } },
|
if: { kind: 'exists', value: { source: 'event', path: 'targetId' } },
|
||||||
reducer: {
|
reducer: {
|
||||||
kind: 'sliding_count', threshold: 3, windowSeconds: 2, reset: 'zero',
|
kind: 'sliding_count',
|
||||||
groupBy: { source: 'event', path: 'targetId' }
|
threshold: 3,
|
||||||
}
|
windowSeconds: 2,
|
||||||
});
|
reset: 'zero',
|
||||||
expect(seedFor('distinct_enemies_within_window').clauses[0]).toMatchObject({
|
groupBy: { source: 'event', path: 'targetId' },
|
||||||
when: 'weapon_hit',
|
},
|
||||||
reducer: {
|
});
|
||||||
kind: 'distinct_count', threshold: 3, windowSeconds: 4,
|
expect(seedFor('distinct_enemies_within_window').clauses[0]).toMatchObject({
|
||||||
distinctBy: { source: 'event', path: 'targetId' }
|
when: 'weapon_hit',
|
||||||
}
|
reducer: {
|
||||||
});
|
kind: 'distinct_count',
|
||||||
expect(seedFor('consecutive_same_target_hits').clauses[0]).toMatchObject({
|
threshold: 3,
|
||||||
when: 'weapon_hit',
|
windowSeconds: 4,
|
||||||
reducer: {
|
distinctBy: { source: 'event', path: 'targetId' },
|
||||||
kind: 'consecutive_count', threshold: 4,
|
},
|
||||||
groupBy: { source: 'event', path: 'targetId' }
|
});
|
||||||
}
|
expect(seedFor('consecutive_same_target_hits').clauses[0]).toMatchObject({
|
||||||
});
|
when: 'weapon_hit',
|
||||||
});
|
reducer: {
|
||||||
|
kind: 'consecutive_count',
|
||||||
|
threshold: 4,
|
||||||
|
groupBy: { source: 'event', path: 'targetId' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('maps head and body shots to authoritative weapon-hit predicates', () => {
|
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 [
|
||||||
expect(seedFor(action).clauses[0]).toMatchObject({
|
['headshot', true],
|
||||||
when: 'weapon_hit',
|
['body_shot', false],
|
||||||
if: {
|
] as const) {
|
||||||
kind: 'all',
|
expect(seedFor(action).clauses[0]).toMatchObject({
|
||||||
predicates: [
|
when: 'weapon_hit',
|
||||||
{ kind: 'exists', value: { source: 'event', path: 'targetId' } },
|
if: {
|
||||||
{ kind: 'exists', value: { source: 'event', path: 'values.headshot' } },
|
kind: 'all',
|
||||||
{
|
predicates: [
|
||||||
kind: 'compare',
|
{ kind: 'exists', value: { source: 'event', path: 'targetId' } },
|
||||||
left: { source: 'event', path: 'values.headshot' },
|
{
|
||||||
operator: 'eq',
|
kind: 'exists',
|
||||||
right: headshot
|
value: { source: 'event', path: 'values.headshot' },
|
||||||
}
|
},
|
||||||
]
|
{
|
||||||
}
|
kind: 'compare',
|
||||||
});
|
left: { source: 'event', path: 'values.headshot' },
|
||||||
}
|
operator: 'eq',
|
||||||
});
|
right: headshot,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('models firing triggers as explicit lifecycle branches', () => {
|
it('models firing triggers as explicit lifecycle branches', () => {
|
||||||
const continuous = seedFor('continuous_firing_duration');
|
const continuous = seedFor('continuous_firing_duration');
|
||||||
expect(continuous.pools).toEqual([expect.objectContaining({ key: 'firing-active', max: 1 })]);
|
expect(continuous.pools).toEqual([
|
||||||
expect(continuous.clauses.map((clause) => clause.when)).toEqual([
|
expect.objectContaining({ key: 'firing-active', max: 1 }),
|
||||||
'firing_started', 'firing_stopped', 'reload_started', 'tick'
|
]);
|
||||||
]);
|
expect(continuous.clauses.map((clause) => clause.when)).toEqual([
|
||||||
expect(continuous.clauses.at(-1)).toMatchObject({
|
'firing_started',
|
||||||
reducer: { kind: 'duration', seconds: 2.5 },
|
'firing_stopped',
|
||||||
if: { kind: 'compare', left: { source: 'pool', pool: 'firing-active' }, operator: 'gt', right: 0 }
|
'reload_started',
|
||||||
});
|
'tick',
|
||||||
expect(seedFor('while_firing').clauses.map((clause) => clause.when)).toEqual([
|
]);
|
||||||
'firing_started', 'firing_stopped', 'reload_started'
|
expect(continuous.clauses.at(-1)).toMatchObject({
|
||||||
]);
|
reducer: { kind: 'duration', seconds: 2.5 },
|
||||||
});
|
if: {
|
||||||
|
kind: 'compare',
|
||||||
|
left: { source: 'pool', pool: 'firing-active' },
|
||||||
|
operator: 'gt',
|
||||||
|
right: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
seedFor('while_firing').clauses.map((clause) => clause.when),
|
||||||
|
).toEqual(['firing_started', 'firing_stopped', 'reload_started']);
|
||||||
|
});
|
||||||
|
|
||||||
it('models health bands and persistent distance and Extra Health state as clearable branches', () => {
|
it('models health bands and persistent distance and Extra Health state as clearable branches', () => {
|
||||||
const health = seedFor('health_threshold_bands');
|
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([
|
||||||
expect(health.clauses.map((clause) => clause.id)).toEqual([
|
'health-band-1',
|
||||||
'select-health-band-1', 'select-health-band-2', 'clear-health-bands'
|
'health-band-2',
|
||||||
]);
|
]);
|
||||||
expect(health.clauses[1].if).toMatchObject({
|
expect(health.clauses.map((clause) => clause.id)).toEqual([
|
||||||
kind: 'all',
|
'select-health-band-1',
|
||||||
predicates: expect.arrayContaining([
|
'select-health-band-2',
|
||||||
{ kind: 'compare', left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' }, operator: 'lt', right: 50 },
|
'clear-health-bands',
|
||||||
{ kind: 'compare', left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' }, operator: 'gte', right: 25 }
|
]);
|
||||||
])
|
expect(health.clauses[1].if).toMatchObject({
|
||||||
});
|
kind: 'all',
|
||||||
expect(health.clauses.every((clause) => clause.when === 'tick')).toBe(true);
|
predicates: expect.arrayContaining([
|
||||||
expect(health.clauses[2].if).toEqual({
|
{
|
||||||
kind: 'compare',
|
kind: 'compare',
|
||||||
left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' },
|
left: {
|
||||||
operator: 'gte',
|
source: 'combat',
|
||||||
right: 50
|
path: 'currentHealthPercent',
|
||||||
});
|
timing: 'current',
|
||||||
|
},
|
||||||
|
operator: 'lt',
|
||||||
|
right: 50,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'compare',
|
||||||
|
left: {
|
||||||
|
source: 'combat',
|
||||||
|
path: 'currentHealthPercent',
|
||||||
|
timing: 'current',
|
||||||
|
},
|
||||||
|
operator: 'gte',
|
||||||
|
right: 25,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
expect(health.clauses.every((clause) => clause.when === 'tick')).toBe(true);
|
||||||
|
expect(health.clauses[2].if).toEqual({
|
||||||
|
kind: 'compare',
|
||||||
|
left: {
|
||||||
|
source: 'combat',
|
||||||
|
path: 'currentHealthPercent',
|
||||||
|
timing: 'current',
|
||||||
|
},
|
||||||
|
operator: 'gte',
|
||||||
|
right: 50,
|
||||||
|
});
|
||||||
|
|
||||||
expect(seedFor('target_hit_within_distance').clauses[0]).toMatchObject({
|
expect(seedFor('target_hit_within_distance').clauses[0]).toMatchObject({
|
||||||
when: 'weapon_hit',
|
when: 'weapon_hit',
|
||||||
if: {
|
if: {
|
||||||
kind: 'all',
|
kind: 'all',
|
||||||
predicates: expect.arrayContaining([
|
predicates: expect.arrayContaining([
|
||||||
{ kind: 'exists', value: { source: 'event', path: 'targetId' } },
|
{ 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');
|
const withinDistance = seedFor('within_distance');
|
||||||
expect(withinDistance.clauses.map((clause) => clause.id)).toEqual([
|
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
|
expect(withinDistance.clauses[0].if).toEqual({
|
||||||
});
|
kind: 'compare',
|
||||||
expect(withinDistance.clauses[1].if).toMatchObject({ kind: 'not' });
|
left: { source: 'event', path: 'values.distanceMeters' },
|
||||||
|
operator: 'lte',
|
||||||
|
right: 10,
|
||||||
|
});
|
||||||
|
expect(withinDistance.clauses[1].if).toMatchObject({ kind: 'not' });
|
||||||
|
|
||||||
const extraHealth = seedFor('extra_health_active');
|
const extraHealth = seedFor('extra_health_active');
|
||||||
expect(extraHealth.clauses.map((clause) => clause.id)).toEqual([
|
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[0].if).toEqual({
|
expect(
|
||||||
kind: 'compare', left: { source: 'event', path: 'values.hasExtraHealth' }, operator: 'eq', right: true
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,336 +1,471 @@
|
|||||||
import type {
|
import type {
|
||||||
ConsecutiveHitsParams,
|
ConsecutiveHitsParams,
|
||||||
ContinuousFiringParams,
|
ContinuousFiringParams,
|
||||||
DistanceParams,
|
DistanceParams,
|
||||||
DistinctEnemiesParams,
|
DistinctEnemiesParams,
|
||||||
HealthThresholdParams,
|
HealthThresholdParams,
|
||||||
SameTargetHitsParams,
|
SameTargetHitsParams,
|
||||||
TriggerActionKey,
|
TriggerActionKey,
|
||||||
TriggerConfiguration,
|
TriggerConfiguration,
|
||||||
TriggerParamsByAction
|
TriggerParamsByAction,
|
||||||
} from './conditional.js';
|
} from './conditional.js';
|
||||||
import type {
|
import type {
|
||||||
MechanicClause,
|
MechanicClause,
|
||||||
MechanicEventKind,
|
MechanicEventKind,
|
||||||
MechanicProgram,
|
MechanicProgram,
|
||||||
PoolDefinition,
|
PoolDefinition,
|
||||||
Predicate,
|
Predicate,
|
||||||
ValueReference
|
ValueReference,
|
||||||
} from './mechanic.js';
|
} from './mechanic.js';
|
||||||
|
|
||||||
/** A partial program that an editor can merge into a new mechanic draft. */
|
/** 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 = {
|
export type SupportedMechanicRecipe = {
|
||||||
status: 'supported';
|
status: 'supported';
|
||||||
seed: MechanicRecipeSeed;
|
seed: MechanicRecipeSeed;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UnsupportedMechanicRecipe = {
|
export type UnsupportedMechanicRecipe = {
|
||||||
status: 'unsupported';
|
status: 'unsupported';
|
||||||
reason: string;
|
reason: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MechanicRecipeResult = SupportedMechanicRecipe | UnsupportedMechanicRecipe;
|
export type MechanicRecipeResult =
|
||||||
|
SupportedMechanicRecipe | UnsupportedMechanicRecipe;
|
||||||
|
|
||||||
export type MechanicRecipeDefinition<K extends TriggerActionKey = TriggerActionKey> = {
|
export type MechanicRecipeDefinition<
|
||||||
key: K;
|
K extends TriggerActionKey = TriggerActionKey,
|
||||||
createSeed: (params: TriggerParamsByAction[K]) => MechanicRecipeResult;
|
> = {
|
||||||
|
key: K;
|
||||||
|
createSeed: (params: TriggerParamsByAction[K]) => MechanicRecipeResult;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MechanicRecipeRegistry = {
|
export type MechanicRecipeRegistry = {
|
||||||
[K in TriggerActionKey]: MechanicRecipeDefinition<K>;
|
[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 = {
|
const targetExists: Predicate = {
|
||||||
kind: 'exists',
|
kind: 'exists',
|
||||||
value: { source: 'event', path: 'targetId' }
|
value: { source: 'event', path: 'targetId' },
|
||||||
};
|
};
|
||||||
const noMutation = [{ kind: 'none' as const }];
|
const noMutation = [{ kind: 'none' as const }];
|
||||||
|
|
||||||
const supported = (seed: MechanicRecipeSeed): SupportedMechanicRecipe => ({ status: 'supported', seed });
|
const supported = (seed: MechanicRecipeSeed): SupportedMechanicRecipe => ({
|
||||||
|
status: 'supported',
|
||||||
|
seed,
|
||||||
|
});
|
||||||
|
|
||||||
function directClause(
|
function directClause(
|
||||||
id: string,
|
id: string,
|
||||||
when: MechanicClause['when'],
|
when: MechanicClause['when'],
|
||||||
ifPredicate?: Predicate
|
ifPredicate?: Predicate,
|
||||||
): MechanicClause {
|
): 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 {
|
function firingLifecycleSeed(
|
||||||
const pools: PoolDefinition[] = [{
|
activePool: string,
|
||||||
key: activePool,
|
includeDuration?: number,
|
||||||
label: 'Firing active',
|
): MechanicRecipeSeed {
|
||||||
scope: 'agent',
|
const pools: PoolDefinition[] = [
|
||||||
initial: 0,
|
{
|
||||||
min: 0,
|
key: activePool,
|
||||||
max: 1
|
label: 'Firing active',
|
||||||
}];
|
scope: 'agent',
|
||||||
const clauses: MechanicClause[] = [
|
initial: 0,
|
||||||
{
|
min: 0,
|
||||||
id: 'start-firing',
|
max: 1,
|
||||||
label: 'Start firing lifecycle',
|
},
|
||||||
when: 'firing_started',
|
];
|
||||||
reducer: { kind: 'none' },
|
const clauses: MechanicClause[] = [
|
||||||
mutations: [{ kind: 'set', pool: activePool, value: 1 }]
|
{
|
||||||
},
|
id: 'start-firing',
|
||||||
{
|
label: 'Start firing lifecycle',
|
||||||
id: 'stop-firing',
|
when: 'firing_started',
|
||||||
label: 'Stop firing lifecycle',
|
reducer: { kind: 'none' },
|
||||||
when: 'firing_stopped',
|
mutations: [{ kind: 'set', pool: activePool, value: 1 }],
|
||||||
reducer: { kind: 'none' },
|
},
|
||||||
mutations: [{ kind: 'clear', pool: activePool }]
|
{
|
||||||
},
|
id: 'stop-firing',
|
||||||
{
|
label: 'Stop firing lifecycle',
|
||||||
id: 'reload-stops-firing',
|
when: 'firing_stopped',
|
||||||
label: 'Reload stops firing lifecycle',
|
reducer: { kind: 'none' },
|
||||||
when: 'reload_started',
|
mutations: [{ kind: 'clear', pool: activePool }],
|
||||||
reducer: { kind: 'none' },
|
},
|
||||||
mutations: [{ kind: 'clear', pool: activePool }]
|
{
|
||||||
}
|
id: 'reload-stops-firing',
|
||||||
];
|
label: 'Reload stops firing lifecycle',
|
||||||
if (includeDuration !== undefined) {
|
when: 'reload_started',
|
||||||
clauses.push({
|
reducer: { kind: 'none' },
|
||||||
id: 'continuous-firing-duration',
|
mutations: [{ kind: 'clear', pool: activePool }],
|
||||||
label: 'Reach continuous firing duration',
|
},
|
||||||
when: 'tick',
|
];
|
||||||
if: {
|
if (includeDuration !== undefined) {
|
||||||
kind: 'compare',
|
clauses.push({
|
||||||
left: { source: 'pool', pool: activePool },
|
id: 'continuous-firing-duration',
|
||||||
operator: 'gt',
|
label: 'Reach continuous firing duration',
|
||||||
right: 0
|
when: 'tick',
|
||||||
},
|
if: {
|
||||||
reducer: { kind: 'duration', seconds: includeDuration },
|
kind: 'compare',
|
||||||
mutations: noMutation
|
left: { source: 'pool', pool: activePool },
|
||||||
});
|
operator: 'gt',
|
||||||
}
|
right: 0,
|
||||||
return { pools, clauses };
|
},
|
||||||
|
reducer: { kind: 'duration', seconds: includeDuration },
|
||||||
|
mutations: noMutation,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { pools, clauses };
|
||||||
}
|
}
|
||||||
|
|
||||||
function healthBandSeed(params: HealthThresholdParams): MechanicRecipeSeed {
|
function healthBandSeed(params: HealthThresholdParams): MechanicRecipeSeed {
|
||||||
const sortedBands = [...params.bands].sort((left, right) => left.thresholdPercent - right.thresholdPercent);
|
const sortedBands = [...params.bands].sort(
|
||||||
const currentHealthPercent: ValueReference = {
|
(left, right) => left.thresholdPercent - right.thresholdPercent,
|
||||||
source: 'combat',
|
);
|
||||||
path: 'currentHealthPercent',
|
const currentHealthPercent: ValueReference = {
|
||||||
timing: 'current'
|
source: 'combat',
|
||||||
};
|
path: 'currentHealthPercent',
|
||||||
const pools: PoolDefinition[] = sortedBands.map((band, index) => ({
|
timing: 'current',
|
||||||
key: `health-band-${index + 1}`,
|
};
|
||||||
label: `Health band: ${band.outcomeKey}`,
|
const pools: PoolDefinition[] = sortedBands.map((band, index) => ({
|
||||||
scope: 'agent',
|
key: `health-band-${index + 1}`,
|
||||||
initial: 0,
|
label: `Health band: ${band.outcomeKey}`,
|
||||||
min: 0,
|
scope: 'agent',
|
||||||
max: 1
|
initial: 0,
|
||||||
}));
|
min: 0,
|
||||||
const clauses: MechanicClause[] = sortedBands.map((band, index) => {
|
max: 1,
|
||||||
const lowerBound = sortedBands[index - 1]?.thresholdPercent;
|
}));
|
||||||
const predicates: Predicate[] = [{
|
const clauses: MechanicClause[] = sortedBands.map((band, index) => {
|
||||||
kind: 'compare',
|
const lowerBound = sortedBands[index - 1]?.thresholdPercent;
|
||||||
left: currentHealthPercent,
|
const predicates: Predicate[] = [
|
||||||
operator: 'lt',
|
{
|
||||||
right: band.thresholdPercent
|
kind: 'compare',
|
||||||
}];
|
left: currentHealthPercent,
|
||||||
if (lowerBound !== undefined) {
|
operator: 'lt',
|
||||||
predicates.push({
|
right: band.thresholdPercent,
|
||||||
kind: 'compare',
|
},
|
||||||
left: currentHealthPercent,
|
];
|
||||||
operator: 'gte',
|
if (lowerBound !== undefined) {
|
||||||
right: lowerBound
|
predicates.push({
|
||||||
});
|
kind: 'compare',
|
||||||
}
|
left: currentHealthPercent,
|
||||||
return {
|
operator: 'gte',
|
||||||
id: `select-health-band-${index + 1}`,
|
right: lowerBound,
|
||||||
label: `Select ${band.outcomeKey} below ${band.thresholdPercent}% Health`,
|
});
|
||||||
when: 'tick',
|
}
|
||||||
if: { kind: 'all', predicates },
|
return {
|
||||||
reducer: { kind: 'none' },
|
id: `select-health-band-${index + 1}`,
|
||||||
mutations: pools.map((pool, poolIndex) =>
|
label: `Select ${band.outcomeKey} below ${band.thresholdPercent}% Health`,
|
||||||
poolIndex === index
|
when: 'tick',
|
||||||
? { kind: 'set' as const, pool: pool.key, value: 1 }
|
if: { kind: 'all', predicates },
|
||||||
: { kind: 'clear' as const, pool: pool.key }
|
reducer: { kind: 'none' },
|
||||||
)
|
mutations: pools.map((pool, poolIndex) =>
|
||||||
};
|
poolIndex === index
|
||||||
});
|
? { kind: 'set' as const, pool: pool.key, value: 1 }
|
||||||
clauses.push({
|
: { kind: 'clear' as const, pool: pool.key },
|
||||||
id: 'clear-health-bands',
|
),
|
||||||
label: 'Clear health bands above the highest threshold',
|
};
|
||||||
when: 'tick',
|
});
|
||||||
if: {
|
clauses.push({
|
||||||
kind: 'compare',
|
id: 'clear-health-bands',
|
||||||
left: currentHealthPercent,
|
label: 'Clear health bands above the highest threshold',
|
||||||
operator: 'gte',
|
when: 'tick',
|
||||||
right: sortedBands.at(-1)?.thresholdPercent ?? 0
|
if: {
|
||||||
},
|
kind: 'compare',
|
||||||
reducer: { kind: 'none' },
|
left: currentHealthPercent,
|
||||||
mutations: pools.map((pool) => ({ kind: 'clear' as const, pool: pool.key }))
|
operator: 'gte',
|
||||||
});
|
right: sortedBands.at(-1)?.thresholdPercent ?? 0,
|
||||||
return { pools, clauses };
|
},
|
||||||
|
reducer: { kind: 'none' },
|
||||||
|
mutations: pools.map((pool) => ({
|
||||||
|
kind: 'clear' as const,
|
||||||
|
pool: pool.key,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
return { pools, clauses };
|
||||||
}
|
}
|
||||||
|
|
||||||
function eventLifecycleSeed(
|
function eventLifecycleSeed(
|
||||||
pool: PoolDefinition,
|
pool: PoolDefinition,
|
||||||
event: MechanicEventKind,
|
event: MechanicEventKind,
|
||||||
activePredicate: Predicate,
|
activePredicate: Predicate,
|
||||||
label: string
|
label: string,
|
||||||
): MechanicRecipeSeed {
|
): MechanicRecipeSeed {
|
||||||
return {
|
return {
|
||||||
pools: [pool],
|
pools: [pool],
|
||||||
clauses: [
|
clauses: [
|
||||||
{
|
{
|
||||||
id: `enter-${pool.key}`,
|
id: `enter-${pool.key}`,
|
||||||
label: `Enter ${label}`,
|
label: `Enter ${label}`,
|
||||||
when: event,
|
when: event,
|
||||||
if: activePredicate,
|
if: activePredicate,
|
||||||
reducer: { kind: 'none' },
|
reducer: { kind: 'none' },
|
||||||
mutations: [{ kind: 'set', pool: pool.key, value: 1 }]
|
mutations: [{ kind: 'set', pool: pool.key, value: 1 }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: `exit-${pool.key}`,
|
id: `exit-${pool.key}`,
|
||||||
label: `Exit ${label}`,
|
label: `Exit ${label}`,
|
||||||
when: event,
|
when: event,
|
||||||
if: { kind: 'not', predicate: activePredicate },
|
if: { kind: 'not', predicate: activePredicate },
|
||||||
reducer: { kind: 'none' },
|
reducer: { kind: 'none' },
|
||||||
mutations: [{ kind: 'clear', pool: pool.key }]
|
mutations: [{ kind: 'clear', pool: pool.key }],
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MECHANIC_RECIPE_REGISTRY: Readonly<MechanicRecipeRegistry> = {
|
export const MECHANIC_RECIPE_REGISTRY: Readonly<MechanicRecipeRegistry> = {
|
||||||
same_target_hits_within_window: {
|
same_target_hits_within_window: {
|
||||||
key: 'same_target_hits_within_window',
|
key: 'same_target_hits_within_window',
|
||||||
createSeed: (params) => {
|
createSeed: (params) => {
|
||||||
const value = params as SameTargetHitsParams;
|
const value = params as SameTargetHitsParams;
|
||||||
return supported({ clauses: [{
|
return supported({
|
||||||
id: 'same-target-hit-window',
|
clauses: [
|
||||||
when: 'weapon_hit',
|
{
|
||||||
if: targetExists,
|
id: 'same-target-hit-window',
|
||||||
reducer: {
|
when: 'weapon_hit',
|
||||||
kind: 'sliding_count', threshold: value.hitsRequired, windowSeconds: value.windowSeconds,
|
if: targetExists,
|
||||||
reset: 'zero', groupBy: { source: 'event', path: 'targetId' }
|
reducer: {
|
||||||
},
|
kind: 'sliding_count',
|
||||||
mutations: noMutation
|
threshold: value.hitsRequired,
|
||||||
}] });
|
windowSeconds: value.windowSeconds,
|
||||||
}
|
reset: 'zero',
|
||||||
},
|
groupBy: { source: 'event', path: 'targetId' },
|
||||||
distinct_enemies_within_window: {
|
},
|
||||||
key: 'distinct_enemies_within_window',
|
mutations: noMutation,
|
||||||
createSeed: (params) => {
|
},
|
||||||
const value = params as DistinctEnemiesParams;
|
],
|
||||||
return supported({ clauses: [{
|
});
|
||||||
id: 'distinct-enemy-hit-window', when: 'weapon_hit', if: targetExists,
|
},
|
||||||
reducer: {
|
},
|
||||||
kind: 'distinct_count', threshold: value.enemiesRequired, windowSeconds: value.windowSeconds,
|
distinct_enemies_within_window: {
|
||||||
distinctBy: { source: 'event', path: 'targetId' }
|
key: 'distinct_enemies_within_window',
|
||||||
},
|
createSeed: (params) => {
|
||||||
mutations: noMutation
|
const value = params as DistinctEnemiesParams;
|
||||||
}] });
|
return supported({
|
||||||
}
|
clauses: [
|
||||||
},
|
{
|
||||||
consecutive_same_target_hits: {
|
id: 'distinct-enemy-hit-window',
|
||||||
key: 'consecutive_same_target_hits',
|
when: 'weapon_hit',
|
||||||
createSeed: (params) => {
|
if: targetExists,
|
||||||
const value = params as ConsecutiveHitsParams;
|
reducer: {
|
||||||
return supported({ clauses: [{
|
kind: 'distinct_count',
|
||||||
id: 'consecutive-same-target-hits', when: 'weapon_hit', if: targetExists,
|
threshold: value.enemiesRequired,
|
||||||
reducer: {
|
windowSeconds: value.windowSeconds,
|
||||||
kind: 'consecutive_count', threshold: value.hitsRequired,
|
distinctBy: { source: 'event', path: 'targetId' },
|
||||||
groupBy: { source: 'event', path: 'targetId' }
|
},
|
||||||
},
|
mutations: noMutation,
|
||||||
mutations: noMutation
|
},
|
||||||
}] });
|
],
|
||||||
}
|
});
|
||||||
},
|
},
|
||||||
continuous_firing_duration: {
|
},
|
||||||
key: 'continuous_firing_duration',
|
consecutive_same_target_hits: {
|
||||||
createSeed: (params) => supported(firingLifecycleSeed('firing-active', (params as ContinuousFiringParams).seconds))
|
key: 'consecutive_same_target_hits',
|
||||||
},
|
createSeed: (params) => {
|
||||||
while_firing: {
|
const value = params as ConsecutiveHitsParams;
|
||||||
key: 'while_firing',
|
return supported({
|
||||||
createSeed: (_params) => supported(firingLifecycleSeed('firing-active'))
|
clauses: [
|
||||||
},
|
{
|
||||||
health_threshold_bands: {
|
id: 'consecutive-same-target-hits',
|
||||||
key: 'health_threshold_bands',
|
when: 'weapon_hit',
|
||||||
createSeed: (params) => supported(healthBandSeed(params as HealthThresholdParams))
|
if: targetExists,
|
||||||
},
|
reducer: {
|
||||||
headshot: {
|
kind: 'consecutive_count',
|
||||||
key: 'headshot',
|
threshold: value.hitsRequired,
|
||||||
createSeed: (_params) => supported({ clauses: [directClause('headshot', 'weapon_hit', {
|
groupBy: { source: 'event', path: 'targetId' },
|
||||||
kind: 'all',
|
},
|
||||||
predicates: [
|
mutations: noMutation,
|
||||||
targetExists,
|
},
|
||||||
{ kind: 'exists', value: eventValue('headshot') },
|
],
|
||||||
{ kind: 'compare', left: eventValue('headshot'), operator: 'eq', right: true }
|
});
|
||||||
]
|
},
|
||||||
})] })
|
},
|
||||||
},
|
continuous_firing_duration: {
|
||||||
body_shot: {
|
key: 'continuous_firing_duration',
|
||||||
key: 'body_shot',
|
createSeed: (params) =>
|
||||||
createSeed: (_params) => supported({ clauses: [directClause('body-shot', 'weapon_hit', {
|
supported(
|
||||||
kind: 'all',
|
firingLifecycleSeed(
|
||||||
predicates: [
|
'firing-active',
|
||||||
targetExists,
|
(params as ContinuousFiringParams).seconds,
|
||||||
{ kind: 'exists', value: eventValue('headshot') },
|
),
|
||||||
{ kind: 'compare', left: eventValue('headshot'), operator: 'eq', right: false }
|
),
|
||||||
]
|
},
|
||||||
})] })
|
while_firing: {
|
||||||
},
|
key: 'while_firing',
|
||||||
headshot_kill: {
|
createSeed: (_params) => supported(firingLifecycleSeed('firing-active')),
|
||||||
key: 'headshot_kill',
|
},
|
||||||
createSeed: (_params) => supported({ clauses: [directClause('headshot-kill', 'enemy_killed', {
|
health_threshold_bands: {
|
||||||
kind: 'compare', left: eventValue('headshot'), operator: 'eq', right: true
|
key: 'health_threshold_bands',
|
||||||
})] })
|
createSeed: (params) =>
|
||||||
},
|
supported(healthBandSeed(params as HealthThresholdParams)),
|
||||||
weapon_kill: {
|
},
|
||||||
key: 'weapon_kill',
|
headshot: {
|
||||||
createSeed: (_params) => supported({ clauses: [directClause('weapon-kill', 'enemy_killed')] })
|
key: 'headshot',
|
||||||
},
|
createSeed: (_params) =>
|
||||||
target_hit_within_distance: {
|
supported({
|
||||||
key: 'target_hit_within_distance',
|
clauses: [
|
||||||
createSeed: (params) => {
|
directClause('headshot', 'weapon_hit', {
|
||||||
const value = params as DistanceParams;
|
kind: 'all',
|
||||||
return supported({ clauses: [directClause('target-hit-within-distance', 'weapon_hit', {
|
predicates: [
|
||||||
kind: 'all', predicates: [targetExists, {
|
targetExists,
|
||||||
kind: 'compare', left: eventValue('distanceMeters'), operator: 'lte', right: value.meters
|
{ kind: 'exists', value: eventValue('headshot') },
|
||||||
}]
|
{
|
||||||
})] });
|
kind: 'compare',
|
||||||
}
|
left: eventValue('headshot'),
|
||||||
},
|
operator: 'eq',
|
||||||
damage_taken: {
|
right: true,
|
||||||
key: 'damage_taken',
|
},
|
||||||
createSeed: (_params) => supported({ clauses: [directClause('damage-taken', 'health_damage_taken')] })
|
],
|
||||||
},
|
}),
|
||||||
weapon_swap: {
|
],
|
||||||
key: 'weapon_swap',
|
}),
|
||||||
createSeed: (_params) => supported({ clauses: [directClause('weapon-swap', 'weapon_swapped')] })
|
},
|
||||||
},
|
body_shot: {
|
||||||
reload: {
|
key: 'body_shot',
|
||||||
key: 'reload',
|
createSeed: (_params) =>
|
||||||
createSeed: (_params) => supported({ clauses: [directClause('reload-started', 'reload_started')] })
|
supported({
|
||||||
},
|
clauses: [
|
||||||
within_distance: {
|
directClause('body-shot', 'weapon_hit', {
|
||||||
key: 'within_distance',
|
kind: 'all',
|
||||||
createSeed: (params) => {
|
predicates: [
|
||||||
const value = params as DistanceParams;
|
targetExists,
|
||||||
return supported(eventLifecycleSeed(
|
{ kind: 'exists', value: eventValue('headshot') },
|
||||||
{ key: 'target-within-distance', label: 'Target within distance', scope: 'agent', initial: 0, min: 0, max: 1 },
|
{
|
||||||
'proximity_snapshot',
|
kind: 'compare',
|
||||||
{ kind: 'compare', left: eventValue('distanceMeters'), operator: 'lte', right: value.meters },
|
left: eventValue('headshot'),
|
||||||
`target within ${value.meters}m`
|
operator: 'eq',
|
||||||
));
|
right: false,
|
||||||
}
|
},
|
||||||
},
|
],
|
||||||
extra_health_active: {
|
}),
|
||||||
key: 'extra_health_active',
|
],
|
||||||
createSeed: (_params) => supported(eventLifecycleSeed(
|
}),
|
||||||
{ key: 'extra-health-active', label: 'Extra Health active', scope: 'agent', initial: 0, min: 0, max: 1 },
|
},
|
||||||
'extra_health_changed',
|
headshot_kill: {
|
||||||
{ kind: 'compare', left: eventValue('hasExtraHealth'), operator: 'eq', right: true },
|
key: 'headshot_kill',
|
||||||
'Extra Health active'
|
createSeed: (_params) =>
|
||||||
))
|
supported({
|
||||||
}
|
clauses: [
|
||||||
|
directClause('headshot-kill', 'enemy_killed', {
|
||||||
|
kind: 'compare',
|
||||||
|
left: eventValue('headshot'),
|
||||||
|
operator: 'eq',
|
||||||
|
right: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
weapon_kill: {
|
||||||
|
key: 'weapon_kill',
|
||||||
|
createSeed: (_params) =>
|
||||||
|
supported({ clauses: [directClause('weapon-kill', 'enemy_killed')] }),
|
||||||
|
},
|
||||||
|
target_hit_within_distance: {
|
||||||
|
key: 'target_hit_within_distance',
|
||||||
|
createSeed: (params) => {
|
||||||
|
const value = params as DistanceParams;
|
||||||
|
return supported({
|
||||||
|
clauses: [
|
||||||
|
directClause('target-hit-within-distance', 'weapon_hit', {
|
||||||
|
kind: 'all',
|
||||||
|
predicates: [
|
||||||
|
targetExists,
|
||||||
|
{
|
||||||
|
kind: 'compare',
|
||||||
|
left: eventValue('distanceMeters'),
|
||||||
|
operator: 'lte',
|
||||||
|
right: value.meters,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
damage_taken: {
|
||||||
|
key: 'damage_taken',
|
||||||
|
createSeed: (_params) =>
|
||||||
|
supported({
|
||||||
|
clauses: [directClause('damage-taken', 'health_damage_taken')],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
weapon_swap: {
|
||||||
|
key: 'weapon_swap',
|
||||||
|
createSeed: (_params) =>
|
||||||
|
supported({ clauses: [directClause('weapon-swap', 'weapon_swapped')] }),
|
||||||
|
},
|
||||||
|
reload: {
|
||||||
|
key: 'reload',
|
||||||
|
createSeed: (_params) =>
|
||||||
|
supported({
|
||||||
|
clauses: [directClause('reload-started', 'reload_started')],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
within_distance: {
|
||||||
|
key: 'within_distance',
|
||||||
|
createSeed: (params) => {
|
||||||
|
const value = params as DistanceParams;
|
||||||
|
return supported(
|
||||||
|
eventLifecycleSeed(
|
||||||
|
{
|
||||||
|
key: 'target-within-distance',
|
||||||
|
label: 'Target within distance',
|
||||||
|
scope: 'agent',
|
||||||
|
initial: 0,
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
},
|
||||||
|
'proximity_snapshot',
|
||||||
|
{
|
||||||
|
kind: 'compare',
|
||||||
|
left: eventValue('distanceMeters'),
|
||||||
|
operator: 'lte',
|
||||||
|
right: value.meters,
|
||||||
|
},
|
||||||
|
`target within ${value.meters}m`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extra_health_active: {
|
||||||
|
key: 'extra_health_active',
|
||||||
|
createSeed: (_params) =>
|
||||||
|
supported(
|
||||||
|
eventLifecycleSeed(
|
||||||
|
{
|
||||||
|
key: 'extra-health-active',
|
||||||
|
label: 'Extra Health active',
|
||||||
|
scope: 'agent',
|
||||||
|
initial: 0,
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
},
|
||||||
|
'extra_health_changed',
|
||||||
|
{
|
||||||
|
kind: 'compare',
|
||||||
|
left: eventValue('hasExtraHealth'),
|
||||||
|
operator: 'eq',
|
||||||
|
right: true,
|
||||||
|
},
|
||||||
|
'Extra Health active',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -338,24 +473,28 @@ export const MECHANIC_RECIPE_REGISTRY: Readonly<MechanicRecipeRegistry> = {
|
|||||||
* validated by the trigger family registry.
|
* validated by the trigger family registry.
|
||||||
*/
|
*/
|
||||||
export function createMechanicRecipe<K extends TriggerActionKey>(
|
export function createMechanicRecipe<K extends TriggerActionKey>(
|
||||||
action: K,
|
action: K,
|
||||||
params: TriggerParamsByAction[K]
|
params: TriggerParamsByAction[K],
|
||||||
): MechanicRecipeResult {
|
): MechanicRecipeResult {
|
||||||
const definition = MECHANIC_RECIPE_REGISTRY[action] as MechanicRecipeDefinition<K>;
|
const definition = MECHANIC_RECIPE_REGISTRY[
|
||||||
const result = definition.createSeed(params);
|
action
|
||||||
if (result.status === 'unsupported') return result;
|
] as MechanicRecipeDefinition<K>;
|
||||||
return {
|
const result = definition.createSeed(params);
|
||||||
status: 'supported',
|
if (result.status === 'unsupported') return result;
|
||||||
seed: {
|
return {
|
||||||
...result.seed,
|
status: 'supported',
|
||||||
clauses: result.seed.clauses.map((clause) => ({
|
seed: {
|
||||||
...clause,
|
...result.seed,
|
||||||
recipeKey: action
|
clauses: result.seed.clauses.map((clause) => ({
|
||||||
}))
|
...clause,
|
||||||
}
|
recipeKey: action,
|
||||||
};
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createMechanicRecipeForTrigger(trigger: TriggerConfiguration): MechanicRecipeResult {
|
export function createMechanicRecipeForTrigger(
|
||||||
return createMechanicRecipe(trigger.action, trigger.params);
|
trigger: TriggerConfiguration,
|
||||||
|
): MechanicRecipeResult {
|
||||||
|
return createMechanicRecipe(trigger.action, trigger.params);
|
||||||
}
|
}
|
||||||
|
|||||||
+2492
-1924
File diff suppressed because it is too large
Load Diff
+2765
-2065
File diff suppressed because it is too large
Load Diff
+319
-240
@@ -1,268 +1,347 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
calculateSkillDps,
|
calculateSkillDps,
|
||||||
type SkillAbilityInput,
|
type SkillAbilityInput,
|
||||||
type SkillCalculationInput
|
type SkillCalculationInput,
|
||||||
} from './skill.js';
|
} from './skill.js';
|
||||||
|
|
||||||
const directAbility = (
|
const directAbility = (
|
||||||
overrides: Partial<SkillAbilityInput> = {}
|
overrides: Partial<SkillAbilityInput> = {},
|
||||||
): SkillAbilityInput => ({
|
): SkillAbilityInput => ({
|
||||||
id: 'seeker',
|
id: 'seeker',
|
||||||
baseDamage: 100,
|
baseDamage: 100,
|
||||||
damageType: 'blast',
|
damageType: 'blast',
|
||||||
delivery: { type: 'direct', hits: 2 },
|
delivery: { type: 'direct', hits: 2 },
|
||||||
castSeconds: 0,
|
castSeconds: 0,
|
||||||
cooldownSeconds: 5,
|
cooldownSeconds: 5,
|
||||||
targetCap: 3,
|
targetCap: 3,
|
||||||
chargePolicy: { type: 'none' },
|
chargePolicy: { type: 'none' },
|
||||||
activation: { mode: 'on_cooldown' },
|
activation: { mode: 'on_cooldown' },
|
||||||
...overrides
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
const base = (overrides: Partial<SkillCalculationInput> = {}): SkillCalculationInput => ({
|
const base = (
|
||||||
rules: {
|
overrides: Partial<SkillCalculationInput> = {},
|
||||||
revisionId: 'skill-rules-1',
|
): SkillCalculationInput => ({
|
||||||
bindings: [
|
rules: {
|
||||||
{ attributeId: 'skillDamage', stage: 'skill_damage', operator: 'add' },
|
revisionId: 'skill-rules-1',
|
||||||
{ attributeId: 'criticalChance', stage: 'critical_chance', operator: 'add' },
|
bindings: [
|
||||||
{ attributeId: 'criticalDamage', stage: 'critical_damage', operator: 'add' },
|
{ attributeId: 'skillDamage', stage: 'skill_damage', operator: 'add' },
|
||||||
{ attributeId: 'multishotChance', stage: 'multishot_chance', operator: 'add' },
|
{
|
||||||
{ attributeId: 'multishotDamage', stage: 'multishot_damage', operator: 'add' }
|
attributeId: 'criticalChance',
|
||||||
]
|
stage: 'critical_chance',
|
||||||
},
|
operator: 'add',
|
||||||
abilities: [directAbility()],
|
},
|
||||||
attributes: {
|
{
|
||||||
skillDamage: 20,
|
attributeId: 'criticalDamage',
|
||||||
criticalChance: 25,
|
stage: 'critical_damage',
|
||||||
criticalDamage: 50,
|
operator: 'add',
|
||||||
multishotChance: 20,
|
},
|
||||||
multishotDamage: 100
|
{
|
||||||
},
|
attributeId: 'multishotChance',
|
||||||
assumptions: { encounterSeconds: 10, targetCount: 5 },
|
stage: 'multishot_chance',
|
||||||
...overrides
|
operator: 'add',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
attributeId: 'multishotDamage',
|
||||||
|
stage: 'multishot_damage',
|
||||||
|
operator: 'add',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
abilities: [directAbility()],
|
||||||
|
attributes: {
|
||||||
|
skillDamage: 20,
|
||||||
|
criticalChance: 25,
|
||||||
|
criticalDamage: 50,
|
||||||
|
multishotChance: 20,
|
||||||
|
multishotDamage: 100,
|
||||||
|
},
|
||||||
|
assumptions: { encounterSeconds: 10, targetCount: 5 },
|
||||||
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('calculateSkillDps', () => {
|
describe('calculateSkillDps', () => {
|
||||||
it('calculates critical and multishot expected damage without a headshot model', () => {
|
it('calculates critical and multishot expected damage without a headshot model', () => {
|
||||||
const result = calculateSkillDps(base());
|
const result = calculateSkillDps(base());
|
||||||
expect(result.status).toBe('OK');
|
expect(result.status).toBe('OK');
|
||||||
if (result.status !== 'OK') return;
|
if (result.status !== 'OK') return;
|
||||||
|
|
||||||
const ability = result.abilities[0];
|
const ability = result.abilities[0];
|
||||||
expect(result.revisionId).toBe('skill-rules-1');
|
expect(result.revisionId).toBe('skill-rules-1');
|
||||||
expect(ability.activationCount).toBe(2);
|
expect(ability.activationCount).toBe(2);
|
||||||
expect(ability.damageEventCount).toBe(4);
|
expect(ability.damageEventCount).toBe(4);
|
||||||
expect(ability.effectiveTargets).toBe(3);
|
expect(ability.effectiveTargets).toBe(3);
|
||||||
expect(ability.damage.averageExpectedDamagePerHit).toBe(162);
|
expect(ability.damage.averageExpectedDamagePerHit).toBe(162);
|
||||||
expect(ability.damage.singleTargetDamagePerActivation).toBe(324);
|
expect(ability.damage.singleTargetDamagePerActivation).toBe(324);
|
||||||
expect(ability.damage.singleTargetEncounterDamage).toBe(648);
|
expect(ability.damage.singleTargetEncounterDamage).toBe(648);
|
||||||
expect(ability.damage.aggregateEncounterDamage).toBe(1944);
|
expect(ability.damage.aggregateEncounterDamage).toBe(1944);
|
||||||
expect(ability.damage.singleTargetDps).toBe(64.8);
|
expect(ability.damage.singleTargetDps).toBe(64.8);
|
||||||
expect(ability.damage.aggregateDps).toBe(194.4);
|
expect(ability.damage.aggregateDps).toBe(194.4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reads current modifiers on every periodic tick instead of snapshotting at cast time', () => {
|
it('reads current modifiers on every periodic tick instead of snapshotting at cast time', () => {
|
||||||
const result = calculateSkillDps(base({
|
const result = calculateSkillDps(
|
||||||
abilities: [directAbility({
|
base({
|
||||||
id: 'turret',
|
abilities: [
|
||||||
delivery: { type: 'periodic', ticks: 4, intervalSeconds: 1 },
|
directAbility({
|
||||||
cooldownSeconds: 100,
|
id: 'turret',
|
||||||
targetCap: 1
|
delivery: { type: 'periodic', ticks: 4, intervalSeconds: 1 },
|
||||||
})],
|
cooldownSeconds: 100,
|
||||||
attributes: {},
|
targetCap: 1,
|
||||||
rules: {
|
}),
|
||||||
revisionId: 'dynamic-ticks',
|
],
|
||||||
bindings: [{ attributeId: 'skillDamage', stage: 'skill_damage', operator: 'add' }]
|
attributes: {},
|
||||||
},
|
rules: {
|
||||||
modifierWindows: [{
|
revisionId: 'dynamic-ticks',
|
||||||
startSeconds: 2,
|
bindings: [
|
||||||
endSeconds: 4,
|
{
|
||||||
attributes: { skillDamage: 100 },
|
attributeId: 'skillDamage',
|
||||||
abilityIds: ['turret']
|
stage: 'skill_damage',
|
||||||
}],
|
operator: 'add',
|
||||||
assumptions: { encounterSeconds: 5, targetCount: 1 }
|
},
|
||||||
}));
|
],
|
||||||
expect(result.status).toBe('OK');
|
},
|
||||||
if (result.status !== 'OK') return;
|
modifierWindows: [
|
||||||
|
{
|
||||||
|
startSeconds: 2,
|
||||||
|
endSeconds: 4,
|
||||||
|
attributes: { skillDamage: 100 },
|
||||||
|
abilityIds: ['turret'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
assumptions: { encounterSeconds: 5, targetCount: 1 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.status).toBe('OK');
|
||||||
|
if (result.status !== 'OK') return;
|
||||||
|
|
||||||
expect(result.abilities[0].damage.singleTargetEncounterDamage).toBe(600);
|
expect(result.abilities[0].damage.singleTargetEncounterDamage).toBe(600);
|
||||||
expect(result.abilities[0].damage.averageExpectedDamagePerHit).toBe(150);
|
expect(result.abilities[0].damage.averageExpectedDamagePerHit).toBe(150);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('supports multiple delivered hits on each periodic tick', () => {
|
it('supports multiple delivered hits on each periodic tick', () => {
|
||||||
const result = calculateSkillDps(base({
|
const result = calculateSkillDps(
|
||||||
abilities: [directAbility({
|
base({
|
||||||
id: 'multi-hit-dot',
|
abilities: [
|
||||||
delivery: { type: 'periodic', ticks: 2, hitsPerTick: 3, intervalSeconds: 1 },
|
directAbility({
|
||||||
cooldownSeconds: 100,
|
id: 'multi-hit-dot',
|
||||||
targetCap: 1
|
delivery: {
|
||||||
})],
|
type: 'periodic',
|
||||||
attributes: {},
|
ticks: 2,
|
||||||
rules: { revisionId: 'periodic-hits', bindings: [] },
|
hitsPerTick: 3,
|
||||||
assumptions: { encounterSeconds: 5, targetCount: 1 }
|
intervalSeconds: 1,
|
||||||
}));
|
},
|
||||||
expect(result.status).toBe('OK');
|
cooldownSeconds: 100,
|
||||||
if (result.status !== 'OK') return;
|
targetCap: 1,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
attributes: {},
|
||||||
|
rules: { revisionId: 'periodic-hits', bindings: [] },
|
||||||
|
assumptions: { encounterSeconds: 5, targetCount: 1 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.status).toBe('OK');
|
||||||
|
if (result.status !== 'OK') return;
|
||||||
|
|
||||||
expect(result.abilities[0].damageEventCount).toBe(6);
|
expect(result.abilities[0].damageEventCount).toBe(6);
|
||||||
expect(result.abilities[0].damage.singleTargetEncounterDamage).toBe(600);
|
expect(result.abilities[0].damage.singleTargetEncounterDamage).toBe(600);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applies published cooldown recovery and duration stages to cadence', () => {
|
it('applies published cooldown recovery and duration stages to cadence', () => {
|
||||||
const result = calculateSkillDps(base({
|
const result = calculateSkillDps(
|
||||||
abilities: [directAbility({
|
base({
|
||||||
id: 'cadence',
|
abilities: [
|
||||||
delivery: {
|
directAbility({
|
||||||
type: 'periodic',
|
id: 'cadence',
|
||||||
ticks: 2,
|
delivery: {
|
||||||
hitsPerTick: 1,
|
type: 'periodic',
|
||||||
intervalSeconds: 1,
|
ticks: 2,
|
||||||
firstTickDelaySeconds: 1
|
hitsPerTick: 1,
|
||||||
},
|
intervalSeconds: 1,
|
||||||
cooldownSeconds: 10,
|
firstTickDelaySeconds: 1,
|
||||||
targetCap: 1
|
},
|
||||||
})],
|
cooldownSeconds: 10,
|
||||||
attributes: { recovery: 100, duration: 100 },
|
targetCap: 1,
|
||||||
rules: {
|
}),
|
||||||
revisionId: 'skill-cadence',
|
],
|
||||||
bindings: [
|
attributes: { recovery: 100, duration: 100 },
|
||||||
{ attributeId: 'recovery', stage: 'cooldown_recovery', operator: 'add' },
|
rules: {
|
||||||
{ attributeId: 'duration', stage: 'duration', operator: 'add' }
|
revisionId: 'skill-cadence',
|
||||||
]
|
bindings: [
|
||||||
},
|
{
|
||||||
assumptions: { encounterSeconds: 11, targetCount: 1 }
|
attributeId: 'recovery',
|
||||||
}));
|
stage: 'cooldown_recovery',
|
||||||
expect(result.status).toBe('OK');
|
operator: 'add',
|
||||||
if (result.status !== 'OK') return;
|
},
|
||||||
|
{ attributeId: 'duration', stage: 'duration', operator: 'add' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
assumptions: { encounterSeconds: 11, targetCount: 1 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.status).toBe('OK');
|
||||||
|
if (result.status !== 'OK') return;
|
||||||
|
|
||||||
expect(result.abilities[0].activationCount).toBe(3);
|
expect(result.abilities[0].activationCount).toBe(3);
|
||||||
expect(result.abilities[0].damageEventCount).toBe(8);
|
expect(result.abilities[0].damageEventCount).toBe(8);
|
||||||
expect(result.abilities[0].damage.singleTargetEncounterDamage).toBe(800);
|
expect(result.abilities[0].damage.singleTargetEncounterDamage).toBe(800);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('supports custom uses per minute and clips damage events at encounter end', () => {
|
it('supports custom uses per minute and clips damage events at encounter end', () => {
|
||||||
const result = calculateSkillDps(base({
|
const result = calculateSkillDps(
|
||||||
abilities: [directAbility({
|
base({
|
||||||
delivery: { type: 'periodic', ticks: 3, intervalSeconds: 2 },
|
abilities: [
|
||||||
cooldownSeconds: 0,
|
directAbility({
|
||||||
targetCap: 1,
|
delivery: { type: 'periodic', ticks: 3, intervalSeconds: 2 },
|
||||||
activation: { mode: 'custom_per_minute', usesPerMinute: 4 }
|
cooldownSeconds: 0,
|
||||||
})],
|
targetCap: 1,
|
||||||
attributes: {},
|
activation: { mode: 'custom_per_minute', usesPerMinute: 4 },
|
||||||
rules: { revisionId: 'custom-rate', bindings: [] },
|
}),
|
||||||
assumptions: { encounterSeconds: 31, targetCount: 1 }
|
],
|
||||||
}));
|
attributes: {},
|
||||||
expect(result.status).toBe('OK');
|
rules: { revisionId: 'custom-rate', bindings: [] },
|
||||||
if (result.status !== 'OK') return;
|
assumptions: { encounterSeconds: 31, targetCount: 1 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.status).toBe('OK');
|
||||||
|
if (result.status !== 'OK') return;
|
||||||
|
|
||||||
expect(result.abilities[0].activationCount).toBe(3);
|
expect(result.abilities[0].activationCount).toBe(3);
|
||||||
expect(result.abilities[0].damageEventCount).toBe(7);
|
expect(result.abilities[0].damageEventCount).toBe(7);
|
||||||
expect(result.abilities[0].damage.singleTargetEncounterDamage).toBe(700);
|
expect(result.abilities[0].damage.singleTargetEncounterDamage).toBe(700);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('models independently recharging charges and full-set recharge differently', () => {
|
it('models independently recharging charges and full-set recharge differently', () => {
|
||||||
const shared = {
|
const shared = {
|
||||||
delivery: { type: 'direct', hits: 1 } as const,
|
delivery: { type: 'direct', hits: 1 } as const,
|
||||||
castSeconds: 2,
|
castSeconds: 2,
|
||||||
cooldownSeconds: 10,
|
cooldownSeconds: 10,
|
||||||
targetCap: 1,
|
targetCap: 1,
|
||||||
activation: { mode: 'on_cooldown' } as const
|
activation: { mode: 'on_cooldown' } as const,
|
||||||
};
|
};
|
||||||
const result = calculateSkillDps(base({
|
const result = calculateSkillDps(
|
||||||
abilities: [
|
base({
|
||||||
directAbility({
|
abilities: [
|
||||||
...shared,
|
directAbility({
|
||||||
id: 'independent',
|
...shared,
|
||||||
chargePolicy: { type: 'independent', charges: 3 }
|
id: 'independent',
|
||||||
}),
|
chargePolicy: { type: 'independent', charges: 3 },
|
||||||
directAbility({
|
}),
|
||||||
...shared,
|
directAbility({
|
||||||
id: 'full-set',
|
...shared,
|
||||||
chargePolicy: { type: 'full_set', charges: 3 }
|
id: 'full-set',
|
||||||
})
|
chargePolicy: { type: 'full_set', charges: 3 },
|
||||||
],
|
}),
|
||||||
attributes: {},
|
],
|
||||||
rules: { revisionId: 'charges', bindings: [] },
|
attributes: {},
|
||||||
assumptions: { encounterSeconds: 13, targetCount: 1 }
|
rules: { revisionId: 'charges', bindings: [] },
|
||||||
}));
|
assumptions: { encounterSeconds: 13, targetCount: 1 },
|
||||||
expect(result.status).toBe('OK');
|
}),
|
||||||
if (result.status !== 'OK') return;
|
);
|
||||||
|
expect(result.status).toBe('OK');
|
||||||
|
if (result.status !== 'OK') return;
|
||||||
|
|
||||||
expect(result.abilities.find((item) => item.abilityId === 'independent')?.activationCount).toBe(5);
|
expect(
|
||||||
expect(result.abilities.find((item) => item.abilityId === 'full-set')?.activationCount).toBe(3);
|
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', () => {
|
it('combines every enabled ability and excludes disabled abilities', () => {
|
||||||
const result = calculateSkillDps(base({
|
const result = calculateSkillDps(
|
||||||
abilities: [
|
base({
|
||||||
directAbility({ id: 'one', delivery: { type: 'direct', hits: 1 } }),
|
abilities: [
|
||||||
directAbility({ id: 'two', delivery: { type: 'direct', hits: 1 }, baseDamage: 200 }),
|
directAbility({ id: 'one', delivery: { type: 'direct', hits: 1 } }),
|
||||||
directAbility({
|
directAbility({
|
||||||
id: 'off',
|
id: 'two',
|
||||||
baseDamage: undefined,
|
delivery: { type: 'direct', hits: 1 },
|
||||||
activation: { mode: 'off' }
|
baseDamage: 200,
|
||||||
})
|
}),
|
||||||
],
|
directAbility({
|
||||||
attributes: {},
|
id: 'off',
|
||||||
rules: { revisionId: 'combined', bindings: [] },
|
baseDamage: undefined,
|
||||||
assumptions: { encounterSeconds: 10, targetCount: 1 }
|
activation: { mode: 'off' },
|
||||||
}));
|
}),
|
||||||
expect(result.status).toBe('OK');
|
],
|
||||||
if (result.status !== 'OK') return;
|
attributes: {},
|
||||||
|
rules: { revisionId: 'combined', bindings: [] },
|
||||||
|
assumptions: { encounterSeconds: 10, targetCount: 1 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.status).toBe('OK');
|
||||||
|
if (result.status !== 'OK') return;
|
||||||
|
|
||||||
expect(result.abilities.find((item) => item.abilityId === 'off')).toMatchObject({
|
expect(
|
||||||
enabled: false,
|
result.abilities.find((item) => item.abilityId === 'off'),
|
||||||
activationCount: 0
|
).toMatchObject({
|
||||||
});
|
enabled: false,
|
||||||
expect(result.combined.singleTargetEncounterDamage).toBe(600);
|
activationCount: 0,
|
||||||
expect(result.combined.singleTargetDps).toBe(60);
|
});
|
||||||
});
|
expect(result.combined.singleTargetEncounterDamage).toBe(600);
|
||||||
|
expect(result.combined.singleTargetDps).toBe(60);
|
||||||
|
});
|
||||||
|
|
||||||
it('uses each ability target assumption before applying its target cap', () => {
|
it('uses each ability target assumption before applying its target cap', () => {
|
||||||
const result = calculateSkillDps(base({
|
const result = calculateSkillDps(
|
||||||
abilities: [
|
base({
|
||||||
directAbility({ id: 'single', targetCount: 1 }),
|
abilities: [
|
||||||
directAbility({ id: 'crowd', targetCount: 2 })
|
directAbility({ id: 'single', targetCount: 1 }),
|
||||||
],
|
directAbility({ id: 'crowd', targetCount: 2 }),
|
||||||
attributes: {},
|
],
|
||||||
rules: { revisionId: 'per-ability-targets', bindings: [] },
|
attributes: {},
|
||||||
assumptions: { encounterSeconds: 10, targetCount: 5 }
|
rules: { revisionId: 'per-ability-targets', bindings: [] },
|
||||||
}));
|
assumptions: { encounterSeconds: 10, targetCount: 5 },
|
||||||
expect(result.status).toBe('OK');
|
}),
|
||||||
if (result.status !== 'OK') return;
|
);
|
||||||
|
expect(result.status).toBe('OK');
|
||||||
|
if (result.status !== 'OK') return;
|
||||||
|
|
||||||
expect(result.abilities.find((item) => item.abilityId === 'single')?.effectiveTargets).toBe(1);
|
expect(
|
||||||
expect(result.abilities.find((item) => item.abilityId === 'crowd')?.effectiveTargets).toBe(2);
|
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', () => {
|
it('returns explicit missing requirements instead of partial damage', () => {
|
||||||
const result = calculateSkillDps({
|
const result = calculateSkillDps({
|
||||||
abilities: [directAbility({
|
abilities: [
|
||||||
baseDamage: undefined,
|
directAbility({
|
||||||
targetCap: undefined,
|
baseDamage: undefined,
|
||||||
chargePolicy: undefined,
|
targetCap: undefined,
|
||||||
activation: { mode: 'custom_per_minute', usesPerMinute: 0 }
|
chargePolicy: undefined,
|
||||||
})],
|
activation: { mode: 'custom_per_minute', usesPerMinute: 0 },
|
||||||
attributes: { unknownSkillAttribute: 10 },
|
}),
|
||||||
assumptions: {}
|
],
|
||||||
});
|
attributes: { unknownSkillAttribute: 10 },
|
||||||
expect(result).toMatchObject({
|
assumptions: {},
|
||||||
status: 'INSUFFICIENT_DATA',
|
});
|
||||||
revisionId: null,
|
expect(result).toMatchObject({
|
||||||
abilities: [],
|
status: 'INSUFFICIENT_DATA',
|
||||||
combined: null
|
revisionId: null,
|
||||||
});
|
abilities: [],
|
||||||
expect(result.missing).toEqual(expect.arrayContaining([
|
combined: null,
|
||||||
'rules.revisionId',
|
});
|
||||||
'rules.bindings',
|
expect(result.missing).toEqual(
|
||||||
'assumptions.encounterSeconds',
|
expect.arrayContaining([
|
||||||
'assumptions.targetCount',
|
'rules.revisionId',
|
||||||
'ability:seeker:baseDamage',
|
'rules.bindings',
|
||||||
'ability:seeker:targetCap',
|
'assumptions.encounterSeconds',
|
||||||
'ability:seeker:chargePolicy',
|
'assumptions.targetCount',
|
||||||
'ability:seeker:activation.usesPerMinute',
|
'ability:seeker:baseDamage',
|
||||||
'binding:unknownSkillAttribute'
|
'ability:seeker:targetCap',
|
||||||
]));
|
'ability:seeker:chargePolicy',
|
||||||
});
|
'ability:seeker:activation.usesPerMinute',
|
||||||
|
'binding:unknownSkillAttribute',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+561
-456
File diff suppressed because it is too large
Load Diff
@@ -1,102 +1,139 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import type { ConditionalMechanicDraftV1 } from './mechanic-editor';
|
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 = (
|
||||||
version: 1,
|
outcomes: ConditionalMechanicDraftV1['clauses'][number]['outcomes'],
|
||||||
simulationReadiness: 'ready',
|
): ConditionalMechanicDraftV1 => ({
|
||||||
scenarioInputs: [],
|
version: 1,
|
||||||
pools: [],
|
simulationReadiness: 'ready',
|
||||||
clauses: [{
|
scenarioInputs: [],
|
||||||
key: 'primary',
|
pools: [],
|
||||||
label: 'Primary',
|
clauses: [
|
||||||
event: 'enemy_killed',
|
{
|
||||||
if: { kind: 'true' },
|
key: 'primary',
|
||||||
reducer: { kind: 'none' },
|
label: 'Primary',
|
||||||
mutations: [],
|
event: 'enemy_killed',
|
||||||
outcomes
|
if: { kind: 'true' },
|
||||||
}]
|
reducer: { kind: 'none' },
|
||||||
|
mutations: [],
|
||||||
|
outcomes,
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('talent description outcomes', () => {
|
describe('talent description outcomes', () => {
|
||||||
it('accepts a numbered restoration outcome as a description value', () => {
|
it('accepts a numbered restoration outcome as a description value', () => {
|
||||||
const outcomes = numberedTalentOutcomes(draft([{
|
const outcomes = numberedTalentOutcomes(
|
||||||
key: 'restore-health',
|
draft([
|
||||||
label: 'Restore Health',
|
{
|
||||||
phase: 'after_event',
|
key: 'restore-health',
|
||||||
kind: 'restoration',
|
label: 'Restore Health',
|
||||||
scope: 'self',
|
phase: 'after_event',
|
||||||
resource: 'health',
|
kind: 'restoration',
|
||||||
delivery: 'continuous',
|
scope: 'self',
|
||||||
durationSeconds: 3,
|
resource: 'health',
|
||||||
basis: 'max_health_percent',
|
delivery: 'continuous',
|
||||||
descriptionSlot: 1,
|
durationSeconds: 3,
|
||||||
variants: {
|
basis: 'max_health_percent',
|
||||||
I: { value: 240 },
|
descriptionSlot: 1,
|
||||||
II: { value: 336 }
|
variants: {
|
||||||
}
|
I: { value: 240 },
|
||||||
}]));
|
II: { value: 336 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
expect(outcomes).toHaveLength(1);
|
expect(outcomes).toHaveLength(1);
|
||||||
expect(outcomes[0]).toMatchObject({ kind: 'restoration', descriptionSlot: 1 });
|
expect(outcomes[0]).toMatchObject({
|
||||||
expect(missingTalentDescriptionSlots('Restore {1} over time.', outcomes)).toEqual([]);
|
kind: 'restoration',
|
||||||
});
|
descriptionSlot: 1,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
missingTalentDescriptionSlots('Restore {1} over time.', outcomes),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it('accepts a numbered marker outcome for a structured timing value', () => {
|
it('accepts a numbered marker outcome for a structured timing value', () => {
|
||||||
const outcomes = numberedTalentOutcomes(draft([{
|
const outcomes = numberedTalentOutcomes(
|
||||||
key: 'delayed-reload',
|
draft([
|
||||||
label: 'Delayed reload',
|
{
|
||||||
phase: 'after_event',
|
key: 'delayed-reload',
|
||||||
kind: 'marker',
|
label: 'Delayed reload',
|
||||||
scope: 'equipped_weapon',
|
phase: 'after_event',
|
||||||
basis: 'flat',
|
kind: 'marker',
|
||||||
descriptionSlot: 1,
|
scope: 'equipped_weapon',
|
||||||
variants: {
|
basis: 'flat',
|
||||||
I: { value: 4 },
|
descriptionSlot: 1,
|
||||||
II: { value: 3 }
|
variants: {
|
||||||
}
|
I: { value: 4 },
|
||||||
}]));
|
II: { value: 3 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
expect(outcomes).toHaveLength(1);
|
expect(outcomes).toHaveLength(1);
|
||||||
expect(outcomes[0]).toMatchObject({ kind: 'marker', descriptionSlot: 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', () => {
|
it('accepts a numbered weapon-reload delay', () => {
|
||||||
const outcomes = numberedTalentOutcomes(draft([{
|
const outcomes = numberedTalentOutcomes(
|
||||||
key: 'inactive-reload',
|
draft([
|
||||||
label: 'Inactive weapon reload',
|
{
|
||||||
phase: 'after_event',
|
key: 'inactive-reload',
|
||||||
kind: 'weapon_reload',
|
label: 'Inactive weapon reload',
|
||||||
scope: 'inactive_weapon',
|
phase: 'after_event',
|
||||||
basis: 'flat',
|
kind: 'weapon_reload',
|
||||||
descriptionSlot: 1,
|
scope: 'inactive_weapon',
|
||||||
variants: {
|
basis: 'flat',
|
||||||
I: { value: 4 },
|
descriptionSlot: 1,
|
||||||
II: { value: 3 }
|
variants: {
|
||||||
}
|
I: { value: 4 },
|
||||||
}]));
|
II: { value: 3 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
expect(outcomes).toHaveLength(1);
|
expect(outcomes).toHaveLength(1);
|
||||||
expect(outcomes[0]).toMatchObject({ kind: 'weapon_reload', descriptionSlot: 1 });
|
expect(outcomes[0]).toMatchObject({
|
||||||
expect(missingTalentDescriptionSlots('Reload after {1} seconds.', outcomes)).toEqual([]);
|
kind: 'weapon_reload',
|
||||||
});
|
descriptionSlot: 1,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
missingTalentDescriptionSlots('Reload after {1} seconds.', outcomes),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it('reports exact missing slots instead of relying on outcome count', () => {
|
it('reports exact missing slots instead of relying on outcome count', () => {
|
||||||
const outcomes = numberedTalentOutcomes(draft([{
|
const outcomes = numberedTalentOutcomes(
|
||||||
key: 'damage',
|
draft([
|
||||||
label: 'Damage',
|
{
|
||||||
phase: 'on_event',
|
key: 'damage',
|
||||||
kind: 'direct_damage',
|
label: 'Damage',
|
||||||
scope: 'target',
|
phase: 'on_event',
|
||||||
basis: 'flat',
|
kind: 'direct_damage',
|
||||||
descriptionSlot: 2,
|
scope: 'target',
|
||||||
variants: {
|
basis: 'flat',
|
||||||
I: { value: 10 },
|
descriptionSlot: 2,
|
||||||
II: { value: 14 }
|
variants: {
|
||||||
}
|
I: { value: 10 },
|
||||||
}]));
|
II: { value: 14 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
expect(missingTalentDescriptionSlots('{1} then {2} and {3}.', outcomes)).toEqual([1, 3]);
|
expect(
|
||||||
});
|
missingTalentDescriptionSlots('{1} then {2} and {3}.', outcomes),
|
||||||
|
).toEqual([1, 3]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,40 +1,52 @@
|
|||||||
import type {
|
import type {
|
||||||
ConditionalMechanicDraftV1,
|
ConditionalMechanicDraftV1,
|
||||||
ConditionalMechanicOutcomeDraft
|
ConditionalMechanicOutcomeDraft,
|
||||||
} from '$lib/calculations/mechanic-editor';
|
} from '$lib/calculations/mechanic-editor';
|
||||||
|
|
||||||
const VALUE_BEARING_OUTCOME_KINDS = new Set([
|
const VALUE_BEARING_OUTCOME_KINDS = new Set([
|
||||||
'attribute_modifier',
|
'attribute_modifier',
|
||||||
'direct_damage',
|
'direct_damage',
|
||||||
'restoration',
|
'restoration',
|
||||||
'resource_cost',
|
'resource_cost',
|
||||||
'weapon_reload',
|
'weapon_reload',
|
||||||
'marker'
|
'marker',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const numberedTalentOutcomes = (
|
export const numberedTalentOutcomes = (
|
||||||
draft: ConditionalMechanicDraftV1
|
draft: ConditionalMechanicDraftV1,
|
||||||
): ConditionalMechanicOutcomeDraft[] => {
|
): ConditionalMechanicOutcomeDraft[] => {
|
||||||
const outcomes = draft.clauses
|
const outcomes = draft.clauses
|
||||||
.flatMap((clause) => clause.outcomes)
|
.flatMap((clause) => clause.outcomes)
|
||||||
.filter((outcome) =>
|
.filter(
|
||||||
VALUE_BEARING_OUTCOME_KINDS.has(outcome.kind)
|
(outcome) =>
|
||||||
&& Number.isInteger(outcome.descriptionSlot)
|
VALUE_BEARING_OUTCOME_KINDS.has(outcome.kind) &&
|
||||||
&& Number(outcome.descriptionSlot) > 0
|
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 = (
|
export const missingTalentDescriptionSlots = (
|
||||||
template: string,
|
template: string,
|
||||||
outcomes: ConditionalMechanicOutcomeDraft[]
|
outcomes: ConditionalMechanicOutcomeDraft[],
|
||||||
) => {
|
) => {
|
||||||
const availableSlots = new Set(outcomes.map((outcome) => Number(outcome.descriptionSlot)));
|
const availableSlots = new Set(
|
||||||
return [...new Set(
|
outcomes.map((outcome) => Number(outcome.descriptionSlot)),
|
||||||
[...template.matchAll(/\{(\d+)\}/g)]
|
);
|
||||||
.map((match) => Number(match[1]))
|
return [
|
||||||
.filter((slot) => !availableSlots.has(slot))
|
...new Set(
|
||||||
)];
|
[...template.matchAll(/\{(\d+)\}/g)]
|
||||||
|
.map((match) => Number(match[1]))
|
||||||
|
.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',
|
label = 'REPORT',
|
||||||
result,
|
result,
|
||||||
plain = false,
|
plain = false,
|
||||||
menuItem = false
|
menuItem = false,
|
||||||
|
itemId = ''
|
||||||
}: {
|
}: {
|
||||||
contentType: 'comment' | 'profile_bio' | 'guide' | 'avatar';
|
contentType: 'comment' | 'profile_bio' | 'guide' | 'avatar';
|
||||||
contentId: string;
|
contentId: string;
|
||||||
@@ -15,6 +16,7 @@
|
|||||||
result?: Record<string, unknown> | null;
|
result?: Record<string, unknown> | null;
|
||||||
plain?: boolean;
|
plain?: boolean;
|
||||||
menuItem?: boolean;
|
menuItem?: boolean;
|
||||||
|
itemId?: string;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const categories = [
|
const categories = [
|
||||||
@@ -48,6 +50,7 @@
|
|||||||
{#snippet children(pending)}
|
{#snippet children(pending)}
|
||||||
<input type="hidden" name="contentType" value={contentType}/>
|
<input type="hidden" name="contentType" value={contentType}/>
|
||||||
<input type="hidden" name="contentId" value={contentId}/>
|
<input type="hidden" name="contentId" value={contentId}/>
|
||||||
|
{#if itemId}<input type="hidden" name="itemId" value={itemId}/>{/if}
|
||||||
<label class="field">CONCERN
|
<label class="field">CONCERN
|
||||||
<select name="category" required>
|
<select name="category" required>
|
||||||
{#each categories as category}<option value={category.value}>{category.label}</option>{/each}
|
{#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 DatabaseFacts from './DatabaseFacts.svelte';
|
||||||
import type { GameIconName } from '$lib/game-icons';
|
import type { GameIconName } from '$lib/game-icons';
|
||||||
import type { DatabaseItem } from '$lib/database/items';
|
import type { DatabaseItem } from '$lib/database/items';
|
||||||
|
import MarkdownContent from './MarkdownContent.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
item
|
item,
|
||||||
|
hideDiscussionUtility = false,
|
||||||
|
anchor = true
|
||||||
}: {
|
}: {
|
||||||
item: DatabaseItem & { gameIcon?: GameIconName }
|
item: DatabaseItem & { gameIcon?: GameIconName };
|
||||||
|
hideDiscussionUtility?: boolean;
|
||||||
|
anchor?: boolean;
|
||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</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>
|
<header>
|
||||||
{#if item.renderUrl}<img class="database-item-render" src={item.renderUrl} alt="" loading="lazy"/>{/if}
|
{#if item.renderUrl}<img class="database-item-render" src={item.renderUrl} alt="" loading="lazy"/>{/if}
|
||||||
<div class="database-item-icon">
|
<div class="database-item-icon">
|
||||||
@@ -55,7 +60,30 @@
|
|||||||
{#if item.afterLines?.length}
|
{#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>
|
<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}
|
||||||
{#if item.actions?.length}
|
{#if item.pinnedCommunityNoteHtml}<aside class="database-community-note"><strong>COMMUNITY NOTE</strong><MarkdownContent html={item.pinnedCommunityNoteHtml} compact/></aside>{/if}
|
||||||
<footer class="database-item-actions">{#each item.actions as action}<a href={action.href}>{action.label} →</a>{/each}</footer>
|
{#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}
|
{/if}
|
||||||
</article>
|
</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}
|
aria-expanded={open}
|
||||||
onclick={toggle}
|
onclick={toggle}
|
||||||
>
|
>
|
||||||
<span aria-hidden="true">•••</span>
|
<span aria-hidden="true">...</span>
|
||||||
</button>
|
</button>
|
||||||
{#if open}
|
{#if open}
|
||||||
<div class="overflow-menu-panel" role="toolbar" aria-label={label} tabindex="-1" onkeydown={navigate}>
|
<div class="overflow-menu-panel" role="toolbar" aria-label={label} tabindex="-1" onkeydown={navigate}>
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<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(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}}
|
@media(prefers-reduced-motion:reduce){.overflow-menu-trigger{transition:none}}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,15 +1,61 @@
|
|||||||
export const databaseCategories = [
|
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: 'gear',
|
||||||
{ slug: 'weapon-mods', label: 'Weapon Mods', description: 'Slot and weapon compatibility, quality rolls, secondary pools, and Mod Effects.' },
|
label: 'Gear',
|
||||||
{ slug: 'gear-mods', label: 'Gear Mods', description: 'Quality-scaled primary rolls and selectable secondary attribute pools.' },
|
description:
|
||||||
{ slug: 'os-protocols', label: 'OS Protocols', description: 'Quality, core profile, primary effect, and unique upgradeable talent.' },
|
'Equipment slots, base health and armor, tiers, Brand Sets, and talents.',
|
||||||
{ 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: 'weapons',
|
||||||
{ slug: 'specializations', label: 'Specializations', description: 'Specialization identities, focus paths, signature abilities, and tree node progression.' }
|
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;
|
] 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);
|
||||||
|
|||||||
+39
-33
@@ -1,48 +1,54 @@
|
|||||||
import type { GameIconName } from '$lib/game-icons';
|
import type { GameIconName } from '$lib/game-icons';
|
||||||
|
import type { GameDataTargetType } from '$lib/game-data-targets';
|
||||||
|
|
||||||
export type DatabaseBadge = { label: string; tone?: string };
|
export type DatabaseBadge = { label: string; tone?: string };
|
||||||
export type DatabaseMeter = { label: string; value: number };
|
export type DatabaseMeter = { label: string; value: number };
|
||||||
export type DatabaseFact = {
|
export type DatabaseFact = {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
emphasis?: 'primary';
|
emphasis?: 'primary';
|
||||||
gameIcon?: GameIconName;
|
gameIcon?: GameIconName;
|
||||||
note?: string;
|
note?: string;
|
||||||
meters?: DatabaseMeter[];
|
meters?: DatabaseMeter[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DatabaseItem = {
|
export type DatabaseItem = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
targetType?: GameDataTargetType;
|
||||||
kicker: string;
|
correctionHref?: string;
|
||||||
variant?: 'talent';
|
discussionHref?: string;
|
||||||
iconUrl?: string;
|
discussionCount?: number;
|
||||||
renderUrl?: string;
|
pinnedCommunityNoteHtml?: string;
|
||||||
gameIcon?: GameIconName;
|
name: string;
|
||||||
headerBadge?: string;
|
kicker: string;
|
||||||
headerBadges?: string[];
|
variant?: 'talent';
|
||||||
headerMetaIcon?: GameIconName;
|
iconUrl?: string;
|
||||||
badges?: DatabaseBadge[];
|
renderUrl?: string;
|
||||||
facts: DatabaseFact[];
|
gameIcon?: GameIconName;
|
||||||
factLayout?: 'grid' | 'stack';
|
headerBadge?: string;
|
||||||
actions?: Array<{ label: string; href: string }>;
|
headerBadges?: string[];
|
||||||
lines?: DatabaseFact[];
|
headerMetaIcon?: GameIconName;
|
||||||
afterLines?: DatabaseFact[];
|
badges?: DatabaseBadge[];
|
||||||
iconGroups?: DatabaseIconGroup[];
|
facts: DatabaseFact[];
|
||||||
description?: string;
|
factLayout?: 'grid' | 'stack';
|
||||||
secondaryDescription?: string;
|
actions?: Array<{ label: string; href: string }>;
|
||||||
descriptionStyle?: 'prose' | 'data';
|
lines?: DatabaseFact[];
|
||||||
|
afterLines?: DatabaseFact[];
|
||||||
|
iconGroups?: DatabaseIconGroup[];
|
||||||
|
description?: string;
|
||||||
|
secondaryDescription?: string;
|
||||||
|
descriptionStyle?: 'prose' | 'data';
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DatabaseRelatedItem = {
|
export type DatabaseRelatedItem = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
iconUrl?: string;
|
iconUrl?: string;
|
||||||
preview?: DatabaseItem;
|
preview?: DatabaseItem;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DatabaseIconGroup = {
|
export type DatabaseIconGroup = {
|
||||||
label: string;
|
label: string;
|
||||||
items: DatabaseRelatedItem[];
|
items: DatabaseRelatedItem[];
|
||||||
emptyLabel?: string;
|
emptyLabel?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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)}`;
|
||||||
+35
-26
@@ -1,30 +1,39 @@
|
|||||||
export const gameIcons = {
|
export const gameIcons = {
|
||||||
blast: { src: '/blast.png', label: 'Blast damage' },
|
blast: { src: '/blast.png', label: 'Blast damage' },
|
||||||
pierce: { src: '/pierce.png', label: 'Pierce damage' },
|
pierce: { src: '/pierce.png', label: 'Pierce damage' },
|
||||||
shredding: { src: '/shredding.png', label: 'Shredding damage' },
|
shredding: { src: '/shredding.png', label: 'Shredding damage' },
|
||||||
firepower: { src: '/firepower.png', label: 'Firepower' },
|
firepower: { src: '/firepower.png', label: 'Firepower' },
|
||||||
toughness: { src: '/toughness.png', label: 'Toughness' },
|
toughness: { src: '/toughness.png', label: 'Toughness' },
|
||||||
engineering: { src: '/engineering.png', label: 'Engineering' },
|
engineering: { src: '/engineering.png', label: 'Engineering' },
|
||||||
bulwark: { src: '/bulwark.png', label: 'Bulwark specialization' },
|
bulwark: { src: '/bulwark.png', label: 'Bulwark specialization' },
|
||||||
combat_medic: { src: '/combat_medic.png', label: 'Combat Medic specialization' },
|
combat_medic: {
|
||||||
demolitionist: { src: '/demolitionist.png', label: 'Demolitionist specialization' },
|
src: '/combat_medic.png',
|
||||||
tech_operator: { src: '/tech_operator.png', label: 'Tech Operator specialization' },
|
label: 'Combat Medic specialization',
|
||||||
vanguard: { src: '/vanguard.png', label: 'Vanguard specialization' },
|
},
|
||||||
legendary: { src: '/legendary.png', label: 'Legendary difficulty' },
|
demolitionist: {
|
||||||
shd_ticket: { src: '/shd_ticket.png', label: 'SHD ticket' },
|
src: '/demolitionist.png',
|
||||||
holster: { src: '/holster.png', label: 'Holster gear slot' },
|
label: 'Demolitionist specialization',
|
||||||
gloves: { src: '/gloves.png', label: 'Gloves gear slot' },
|
},
|
||||||
backpack: { src: '/backpack.png', label: 'Backpack gear slot' },
|
tech_operator: {
|
||||||
knee_pads: { src: '/knee_pads.png', label: 'Knee Pads gear slot' },
|
src: '/tech_operator.png',
|
||||||
body_armor: { src: '/body_armor.png', label: 'Body Armor gear slot' },
|
label: 'Tech Operator specialization',
|
||||||
mask: { src: '/mask.png', label: 'Mask gear slot' },
|
},
|
||||||
exotic: { src: '/exotic.png', label: 'Exotic weapon' },
|
vanguard: { src: '/vanguard.png', label: 'Vanguard specialization' },
|
||||||
shotgun: { src: '/shotgun.png', label: 'Shotgun weapon category' },
|
legendary: { src: '/legendary.png', label: 'Legendary difficulty' },
|
||||||
rifle: { src: '/rifle.png', label: 'Rifle weapon category' },
|
shd_ticket: { src: '/shd_ticket.png', label: 'SHD ticket' },
|
||||||
mmr: { src: '/mmr.png', label: 'Marksman rifle weapon category' },
|
holster: { src: '/holster.png', label: 'Holster gear slot' },
|
||||||
ar: { src: '/ar.png', label: 'Assault rifle weapon category' },
|
gloves: { src: '/gloves.png', label: 'Gloves gear slot' },
|
||||||
lmg: { src: '/lmg.png', label: 'Light machine gun weapon category' },
|
backpack: { src: '/backpack.png', label: 'Backpack gear slot' },
|
||||||
smg: { src: '/smg.png', label: 'Submachine gun weapon category' }
|
knee_pads: { src: '/knee_pads.png', label: 'Knee Pads gear slot' },
|
||||||
|
body_armor: { src: '/body_armor.png', label: 'Body Armor gear slot' },
|
||||||
|
mask: { src: '/mask.png', label: 'Mask gear slot' },
|
||||||
|
exotic: { src: '/exotic.png', label: 'Exotic weapon' },
|
||||||
|
shotgun: { src: '/shotgun.png', label: 'Shotgun weapon category' },
|
||||||
|
rifle: { src: '/rifle.png', label: 'Rifle weapon category' },
|
||||||
|
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' },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type GameIconName = keyof typeof gameIcons;
|
export type GameIconName = keyof typeof gameIcons;
|
||||||
|
|||||||
+122
-96
@@ -1,42 +1,54 @@
|
|||||||
const escapeHtml = (value: string) => value
|
const escapeHtml = (value: string) =>
|
||||||
.replaceAll('&', '&')
|
value
|
||||||
.replaceAll('<', '<')
|
.replaceAll('&', '&')
|
||||||
.replaceAll('>', '>')
|
.replaceAll('<', '<')
|
||||||
.replaceAll('"', '"')
|
.replaceAll('>', '>')
|
||||||
.replaceAll("'", ''');
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
|
||||||
function safeLink(raw: string) {
|
function safeLink(raw: string) {
|
||||||
try {
|
try {
|
||||||
const url = new URL(raw);
|
const url = new URL(raw);
|
||||||
return url.protocol === 'https:' || url.protocol === 'http:' ? url.toString() : null;
|
return url.protocol === 'https:' || url.protocol === 'http:'
|
||||||
} catch {
|
? url.toString()
|
||||||
return null;
|
: null;
|
||||||
}
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function inlineMarkdown(value: string) {
|
function inlineMarkdown(value: string) {
|
||||||
const code: string[] = [];
|
const code: string[] = [];
|
||||||
let output = escapeHtml(value).replace(/`([^`\r\n]+)`/g, (_match, body: string) => {
|
let output = escapeHtml(value).replace(
|
||||||
const token = `\u0000CODE${code.length}\u0000`;
|
/`([^`\r\n]+)`/g,
|
||||||
code.push(`<code>${body}</code>`);
|
(_match, body: string) => {
|
||||||
return token;
|
const token = `\u0000CODE${code.length}\u0000`;
|
||||||
});
|
code.push(`<code>${body}</code>`);
|
||||||
output = output.replace(/\[([^\]\r\n]{1,240})\]\(([^)\s]{1,2048})\)/g, (_match, label: string, href: string) => {
|
return token;
|
||||||
const decodedHref = href
|
},
|
||||||
.replaceAll('&', '&')
|
);
|
||||||
.replaceAll(''', "'")
|
output = output.replace(
|
||||||
.replaceAll('"', '"');
|
/\[([^\]\r\n]{1,240})\]\(([^)\s]{1,2048})\)/g,
|
||||||
const safe = safeLink(decodedHref);
|
(_match, label: string, href: string) => {
|
||||||
return safe
|
const decodedHref = href
|
||||||
? `<a href="${escapeHtml(safe)}" rel="nofollow ugc noopener noreferrer" target="_blank">${label}</a>`
|
.replaceAll('&', '&')
|
||||||
: label;
|
.replaceAll(''', "'")
|
||||||
});
|
.replaceAll('"', '"');
|
||||||
output = output
|
const safe = safeLink(decodedHref);
|
||||||
.replace(/\*\*([^*\r\n]+)\*\*/g, '<strong>$1</strong>')
|
return safe
|
||||||
.replace(/__([^_\r\n]+)__/g, '<strong>$1</strong>')
|
? `<a href="${escapeHtml(safe)}" rel="nofollow ugc noopener noreferrer" target="_blank">${label}</a>`
|
||||||
.replace(/(^|[^\w])\*([^*\r\n]+)\*/g, '$1<em>$2</em>')
|
: label;
|
||||||
.replace(/(^|[^\w])_([^_\r\n]+)_/g, '$1<em>$2</em>');
|
},
|
||||||
return output.replace(/\u0000CODE(\d+)\u0000/g, (_match, index: string) => code[Number(index)] ?? '');
|
);
|
||||||
|
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)] ?? '',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -45,70 +57,84 @@ function inlineMarkdown(value: string) {
|
|||||||
* nofollow, and opener isolation attributes.
|
* nofollow, and opener isolation attributes.
|
||||||
*/
|
*/
|
||||||
export function renderUserMarkdown(markdown: unknown): string {
|
export function renderUserMarkdown(markdown: unknown): string {
|
||||||
const input = String(markdown ?? '').normalize('NFKC').replace(/\r\n?/g, '\n').trim();
|
const input = String(markdown ?? '')
|
||||||
if (!input) return '';
|
.normalize('NFKC')
|
||||||
const lines = input.split('\n');
|
.replace(/\r\n?/g, '\n')
|
||||||
const output: string[] = [];
|
.trim();
|
||||||
let paragraph: string[] = [];
|
if (!input) return '';
|
||||||
let list: 'ul' | 'ol' | null = null;
|
const lines = input.split('\n');
|
||||||
|
const output: string[] = [];
|
||||||
|
let paragraph: string[] = [];
|
||||||
|
let list: 'ul' | 'ol' | null = null;
|
||||||
|
|
||||||
const closeParagraph = () => {
|
const closeParagraph = () => {
|
||||||
if (!paragraph.length) return;
|
if (!paragraph.length) return;
|
||||||
output.push(`<p>${inlineMarkdown(paragraph.join(' '))}</p>`);
|
output.push(`<p>${inlineMarkdown(paragraph.join(' '))}</p>`);
|
||||||
paragraph = [];
|
paragraph = [];
|
||||||
};
|
};
|
||||||
const closeList = () => {
|
const closeList = () => {
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
output.push(`</${list}>`);
|
output.push(`</${list}>`);
|
||||||
list = null;
|
list = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
closeParagraph();
|
closeParagraph();
|
||||||
closeList();
|
closeList();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const heading = /^(#{1,3})\s+(.+)$/.exec(trimmed);
|
const heading = /^(#{1,3})\s+(.+)$/.exec(trimmed);
|
||||||
if (heading) {
|
if (heading) {
|
||||||
closeParagraph();
|
closeParagraph();
|
||||||
closeList();
|
closeList();
|
||||||
const level = heading[1].length + 2;
|
const level = heading[1].length + 2;
|
||||||
output.push(`<h${level}>${inlineMarkdown(heading[2])}</h${level}>`);
|
output.push(`<h${level}>${inlineMarkdown(heading[2])}</h${level}>`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const unordered = /^[-*]\s+(.+)$/.exec(trimmed);
|
const unordered = /^[-*]\s+(.+)$/.exec(trimmed);
|
||||||
const ordered = /^\d+[.)]\s+(.+)$/.exec(trimmed);
|
const ordered = /^\d+[.)]\s+(.+)$/.exec(trimmed);
|
||||||
if (unordered || ordered) {
|
if (unordered || ordered) {
|
||||||
closeParagraph();
|
closeParagraph();
|
||||||
const nextList = unordered ? 'ul' : 'ol';
|
const nextList = unordered ? 'ul' : 'ol';
|
||||||
if (list !== nextList) {
|
if (list !== nextList) {
|
||||||
closeList();
|
closeList();
|
||||||
list = nextList;
|
list = nextList;
|
||||||
output.push(`<${list}>`);
|
output.push(`<${list}>`);
|
||||||
}
|
}
|
||||||
output.push(`<li>${inlineMarkdown((unordered ?? ordered)?.[1] ?? '')}</li>`);
|
output.push(
|
||||||
continue;
|
`<li>${inlineMarkdown((unordered ?? ordered)?.[1] ?? '')}</li>`,
|
||||||
}
|
);
|
||||||
const quote = /^>\s?(.+)$/.exec(trimmed);
|
continue;
|
||||||
if (quote) {
|
}
|
||||||
closeParagraph();
|
const quote = /^>\s?(.+)$/.exec(trimmed);
|
||||||
closeList();
|
if (quote) {
|
||||||
output.push(`<blockquote><p>${inlineMarkdown(quote[1])}</p></blockquote>`);
|
closeParagraph();
|
||||||
continue;
|
closeList();
|
||||||
}
|
output.push(
|
||||||
closeList();
|
`<blockquote><p>${inlineMarkdown(quote[1])}</p></blockquote>`,
|
||||||
paragraph.push(trimmed);
|
);
|
||||||
}
|
continue;
|
||||||
closeParagraph();
|
}
|
||||||
closeList();
|
closeList();
|
||||||
return output.join('');
|
paragraph.push(trimmed);
|
||||||
|
}
|
||||||
|
closeParagraph();
|
||||||
|
closeList();
|
||||||
|
return output.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeUserMarkdown(markdown: unknown, maxCharacters: number) {
|
export function normalizeUserMarkdown(
|
||||||
const normalized = String(markdown ?? '').normalize('NFKC').replace(/\r\n?/g, '\n').trim();
|
markdown: unknown,
|
||||||
if (Array.from(normalized).length > maxCharacters) return null;
|
maxCharacters: number,
|
||||||
if (/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/u.test(normalized)) return null;
|
) {
|
||||||
return normalized;
|
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;
|
||||||
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,67 +1,106 @@
|
|||||||
import { AVATAR_PRESETS } from '$lib/server/avatars';
|
import { AVATAR_PRESETS } from '$lib/server/avatars';
|
||||||
import { emailDeliveryConfigured } from '$lib/server/auth/mail';
|
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';
|
import type { Database } from '@tursodatabase/database';
|
||||||
|
|
||||||
export async function loadAccountControlData(
|
export async function loadAccountControlData(
|
||||||
database: Database,
|
database: Database,
|
||||||
userId: string,
|
userId: string,
|
||||||
requestedPanel: string | null = null
|
requestedPanel: string | null = null,
|
||||||
) {
|
) {
|
||||||
const [connection, emailIdentity, passkeys, authenticationMethods, notifications, notificationUnread] =
|
const [
|
||||||
await Promise.all([
|
connection,
|
||||||
(await database.prepare(`SELECT users.discord_id,users.login_username,users.password_hash,
|
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.username AS discord_username,discord_profiles.display_name,
|
||||||
discord_profiles.avatar_hash
|
discord_profiles.avatar_hash
|
||||||
FROM users
|
FROM users
|
||||||
LEFT JOIN discord_profiles ON discord_profiles.user_id=users.id
|
LEFT JOIN discord_profiles ON discord_profiles.user_id=users.id
|
||||||
WHERE users.id=?`)).get(userId),
|
WHERE users.id=?`)
|
||||||
(await database.prepare('SELECT email,verified_at FROM user_emails WHERE user_id=?')).get(userId),
|
).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),
|
await database.prepare(
|
||||||
getAuthenticationMethods(database, userId),
|
'SELECT email,verified_at FROM user_emails WHERE user_id=?',
|
||||||
(await database.prepare(`SELECT n.id,n.category,n.source,n.title,n.body,n.link,n.mandatory,
|
)
|
||||||
|
).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,
|
||||||
n.created_at,n.read_at
|
n.created_at,n.read_at
|
||||||
FROM notifications n
|
FROM notifications n
|
||||||
LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category
|
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
|
WHERE n.user_id=? AND n.read_at IS NULL
|
||||||
AND (n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)
|
AND (n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)
|
||||||
ORDER BY n.created_at DESC LIMIT 5`)).all(userId),
|
ORDER BY n.created_at DESC LIMIT 5`)
|
||||||
(await database.prepare(`SELECT COUNT(*) AS count FROM notifications n
|
).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
|
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
|
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))
|
return {
|
||||||
? String(requestedPanel)
|
initialPanel: ['identity', 'access', 'account'].includes(
|
||||||
: 'identity',
|
String(requestedPanel),
|
||||||
loginUsername: connection?.login_username ? String(connection.login_username) : null,
|
)
|
||||||
hasLocalCredentials: typeof connection?.password_hash === 'string'
|
? String(requestedPanel)
|
||||||
&& typeof connection?.login_username === 'string',
|
: 'identity',
|
||||||
discordConnection: connection?.discord_id ? {
|
loginUsername: connection?.login_username
|
||||||
id: String(connection.discord_id),
|
? String(connection.login_username)
|
||||||
username: connection.discord_username ? String(connection.discord_username) : null,
|
: null,
|
||||||
displayName: connection.display_name ? String(connection.display_name) : null,
|
hasLocalCredentials:
|
||||||
avatarHash: connection.avatar_hash ? String(connection.avatar_hash) : null,
|
typeof connection?.password_hash === 'string' &&
|
||||||
canUnlink: canRemoveAuthenticationMethod(authenticationMethods, 'discord')
|
typeof connection?.login_username === 'string',
|
||||||
} : null,
|
discordConnection: connection?.discord_id
|
||||||
emailIdentity: emailIdentity ? {
|
? {
|
||||||
email: String(emailIdentity.email),
|
id: String(connection.discord_id),
|
||||||
verifiedAt: String(emailIdentity.verified_at)
|
username: connection.discord_username
|
||||||
} : null,
|
? String(connection.discord_username)
|
||||||
passkeys: passkeys.map((passkey) => ({
|
: null,
|
||||||
id: String(passkey.id),
|
displayName: connection.display_name
|
||||||
name: String(passkey.name),
|
? String(connection.display_name)
|
||||||
deviceType: String(passkey.device_type),
|
: null,
|
||||||
backedUp: Boolean(passkey.backed_up),
|
avatarHash: connection.avatar_hash
|
||||||
lastUsedAt: passkey.last_used_at ? String(passkey.last_used_at) : null,
|
? String(connection.avatar_hash)
|
||||||
createdAt: String(passkey.created_at)
|
: null,
|
||||||
})),
|
canUnlink: canRemoveAuthenticationMethod(
|
||||||
authenticationMethods,
|
authenticationMethods,
|
||||||
emailDeliveryConfigured: emailDeliveryConfigured(),
|
'discord',
|
||||||
avatarPresets: AVATAR_PRESETS,
|
),
|
||||||
notifications,
|
}
|
||||||
notificationUnread: Number(notificationUnread?.count ?? 0)
|
: null,
|
||||||
};
|
emailIdentity: emailIdentity
|
||||||
|
? {
|
||||||
|
email: String(emailIdentity.email),
|
||||||
|
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),
|
||||||
|
})),
|
||||||
|
authenticationMethods,
|
||||||
|
emailDeliveryConfigured: emailDeliveryConfigured(),
|
||||||
|
avatarPresets: AVATAR_PRESETS,
|
||||||
|
notifications,
|
||||||
|
notificationUnread: Number(notificationUnread?.count ?? 0),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user