From 57068b61cd75fdd314042de6a1bf835e7bc26bc8 Mon Sep 17 00:00:00 2001 From: JB Date: Mon, 3 Aug 2026 01:23:20 -0400 Subject: [PATCH] feat: expand ISAC content modeling --- database/schema.sql | 78 +- docs/DESIGN_SYSTEM.md | 141 +++- docs/architecture.md | 194 +++-- scripts/db-migrate.mjs | 330 +++++++- scripts/db-restore.mjs | 74 +- scripts/import-resurgence-attributes.mjs | 12 +- scripts/isac-data-export.mjs | 2 +- scripts/isac-data-import.mjs | 148 +++- scripts/isac-data-tables.mjs | 2 + scripts/managed-images-export.mjs | 2 +- scripts/managed-images-import.mjs | 19 +- src/app.css | 526 +++++++++++++ src/lib/articles/document.test.ts | 166 ++++ src/lib/articles/document.ts | 68 +- src/lib/builder/loadout-summary.test.ts | 103 +++ src/lib/builder/loadout-summary.ts | 120 ++- src/lib/builder/loadout.test.ts | 64 +- src/lib/builder/loadout.ts | 34 +- src/lib/calculations/mechanic-editor.test.ts | 227 ++++++ src/lib/calculations/mechanic-editor.ts | 137 +++- .../calculations/mechanic-persistence.test.ts | 137 ++++ src/lib/calculations/mechanic-persistence.ts | 55 +- src/lib/calculations/mechanic.test.ts | 677 +++++++++++++++++ src/lib/calculations/mechanic.ts | 709 ++++++++++++++++-- src/lib/components/ArticleContent.svelte | 14 +- src/lib/components/ArticlePage.svelte | 4 +- src/lib/components/DatabaseItemCard.svelte | 48 +- .../components/ModSecondaryPoolEditor.svelte | 125 +++ .../components/ProposalChangePreview.svelte | 23 +- .../components/SkillChipSharedProfiles.svelte | 128 ++++ src/lib/components/WeaponModSlotIcon.svelte | 29 + .../components/WeaponModSlotIconPanel.svelte | 212 ++++++ .../isac/MechanicProgramEditor.svelte | 234 +++++- src/lib/database/items.ts | 15 + src/lib/mods/secondary-pool.test.ts | 115 +++ src/lib/mods/secondary-pool.ts | 102 +++ .../ai/mechanic-authoring-prompt.test.ts | 148 +++- .../server/ai/mechanic-authoring-prompt.ts | 109 ++- .../ai/mechanic-authoring-rate-limit.test.ts | 24 +- .../ai/mechanic-authoring-rate-limit.ts | 7 +- src/lib/server/audit.ts | 3 +- src/lib/server/conditional-effects.test.ts | 56 +- src/lib/server/conditional-effects.ts | 35 +- src/lib/server/data-moderation.ts | 23 +- src/lib/server/game-data-icons.ts | 13 +- src/lib/server/managed-images.test.ts | 169 ++++- src/lib/server/managed-images.ts | 38 +- src/lib/server/mod-secondary-pools.ts | 100 +++ src/lib/server/os-protocol-profiles.test.ts | 96 +++ src/lib/server/os-protocol-profiles.ts | 48 ++ src/lib/server/shared-build-summary.ts | 27 + src/lib/server/skill-chip-profiles.ts | 117 +++ src/lib/server/specialization-readiness.ts | 63 +- src/lib/skill-chips/profiles.test.ts | 179 +++++ src/lib/skill-chips/profiles.ts | 127 ++++ src/lib/weapons/mod-slots.test.ts | 49 ++ src/lib/weapons/mod-slots.ts | 68 +- src/routes/agents/[id]/+page.server.ts | 55 ++ src/routes/agents/[id]/+page.svelte | 49 ++ .../api/isac/calculation-assistant/+server.ts | 5 +- .../api/isac/mechanic-assistant/+server.ts | 5 +- src/routes/builder/+page.server.ts | 143 +++- src/routes/builder/+page.svelte | 135 +++- src/routes/builder/skill-chip-save.test.ts | 186 +++++ src/routes/builds/[slug]/+page.svelte | 2 +- .../database/[category]/+page.server.ts | 128 +++- src/routes/isac/attributes/+page.server.ts | 21 +- src/routes/isac/attributes/+page.svelte | 4 +- src/routes/isac/audit/+page.server.ts | 4 +- .../isac/data-moderation/+page.server.ts | 55 +- src/routes/isac/gearmods/+page.server.ts | 216 +++--- src/routes/isac/gearmods/+page.svelte | 54 +- .../gearmods/secondary-pool-action.test.ts | 134 ++++ .../isac/mechanic-reference/+page.server.ts | 2 +- .../isac/mechanic-reference/+page.svelte | 3 - src/routes/isac/protocols/+page.server.ts | 404 +++++----- src/routes/isac/protocols/+page.svelte | 54 +- .../isac/protocols/profile-action.test.ts | 191 +++++ .../save-actions.integration.test.ts | 231 ++++++ .../isac/protocols/talent-action.test.ts | 344 +++++++++ src/routes/isac/skillchips/+page.server.ts | 588 +++++++++------ src/routes/isac/skillchips/+page.svelte | 53 +- .../isac/skillchips/profile-actions.test.ts | 256 +++++++ .../skillchips/talent-removal-action.test.ts | 225 ++++++ src/routes/isac/specializations/+page.svelte | 8 +- src/routes/isac/weaponmods/+page.server.ts | 297 +++++--- src/routes/isac/weaponmods/+page.svelte | 30 +- .../weaponmods/secondary-pool-action.test.ts | 166 ++++ .../isac/weaponmods/slot-icon-action.test.ts | 157 ++++ tools/game-data-capture/contracts/README.md | 127 +++- 90 files changed, 9635 insertions(+), 1270 deletions(-) create mode 100644 src/lib/components/ModSecondaryPoolEditor.svelte create mode 100644 src/lib/components/SkillChipSharedProfiles.svelte create mode 100644 src/lib/components/WeaponModSlotIcon.svelte create mode 100644 src/lib/components/WeaponModSlotIconPanel.svelte create mode 100644 src/lib/mods/secondary-pool.test.ts create mode 100644 src/lib/mods/secondary-pool.ts create mode 100644 src/lib/server/mod-secondary-pools.ts create mode 100644 src/lib/server/os-protocol-profiles.test.ts create mode 100644 src/lib/server/os-protocol-profiles.ts create mode 100644 src/lib/server/skill-chip-profiles.ts create mode 100644 src/lib/skill-chips/profiles.test.ts create mode 100644 src/lib/skill-chips/profiles.ts create mode 100644 src/routes/builder/skill-chip-save.test.ts create mode 100644 src/routes/isac/gearmods/secondary-pool-action.test.ts create mode 100644 src/routes/isac/protocols/profile-action.test.ts create mode 100644 src/routes/isac/protocols/save-actions.integration.test.ts create mode 100644 src/routes/isac/protocols/talent-action.test.ts create mode 100644 src/routes/isac/skillchips/profile-actions.test.ts create mode 100644 src/routes/isac/skillchips/talent-removal-action.test.ts create mode 100644 src/routes/isac/weaponmods/secondary-pool-action.test.ts create mode 100644 src/routes/isac/weaponmods/slot-icon-action.test.ts diff --git a/database/schema.sql b/database/schema.sql index 3650b9c..9265ac6 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -769,15 +769,12 @@ CREATE TABLE IF NOT EXISTS stat_attributes ( weapon_min REAL, weapon_max REAL, weapon_reference_level INTEGER NOT NULL DEFAULT 50 CHECK(weapon_reference_level BETWEEN 1 AND 50), - skill_chip_min REAL, - skill_chip_max REAL, cap_value REAL, active INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, CHECK((gear_min IS NULL AND gear_max IS NULL) OR (gear_min IS NOT NULL AND gear_max IS NOT NULL AND gear_min <= gear_max)), CHECK((weapon_min IS NULL AND weapon_max IS NULL) OR (weapon_min IS NOT NULL AND weapon_max IS NOT NULL AND weapon_min <= weapon_max)), - CHECK((skill_chip_min IS NULL AND skill_chip_max IS NULL) OR (skill_chip_min IS NOT NULL AND skill_chip_max IS NOT NULL AND skill_chip_min <= skill_chip_max)), CHECK(cap_value IS NULL OR cap_value >= 0) ); CREATE UNIQUE INDEX IF NOT EXISTS stat_attributes_name_unit_nocase_idx ON stat_attributes(name COLLATE NOCASE, unit); @@ -906,6 +903,7 @@ CREATE TABLE IF NOT EXISTS conditional_effect_outcomes ( delivery_mode TEXT NOT NULL DEFAULT 'instant' CHECK(delivery_mode IN ('instant','continuous')), delivery_duration_seconds REAL CHECK(delivery_duration_seconds IS NULL OR delivery_duration_seconds>0), targeting_json TEXT, + probability_percent REAL CHECK(probability_percent IS NULL OR (probability_percent>=0 AND probability_percent<=100)), min_state_value REAL, max_state_value REAL, description_slot INTEGER CHECK(description_slot IS NULL OR description_slot>=1), @@ -1168,14 +1166,30 @@ CREATE TABLE IF NOT EXISTS skill_chip_two_piece_bonuses ( CHECK(max_value >= min_value) ); CREATE TABLE IF NOT EXISTS skill_chip_primary_options ( - skill_chip_set_id TEXT NOT NULL REFERENCES skill_chip_sets(id) ON DELETE CASCADE, - stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id), - PRIMARY KEY(skill_chip_set_id, stat_attribute_id) + stat_attribute_id TEXT PRIMARY KEY REFERENCES stat_attributes(id) +); +CREATE TABLE IF NOT EXISTS skill_chip_primary_quality_values ( + stat_attribute_id TEXT NOT NULL, + quality TEXT NOT NULL CHECK(quality IN ('Standard','Specialized','Superior','High-end')), + min_value REAL NOT NULL, + max_value REAL NOT NULL, + PRIMARY KEY(stat_attribute_id,quality), + FOREIGN KEY(stat_attribute_id) REFERENCES skill_chip_primary_options(stat_attribute_id) ON DELETE CASCADE, + CHECK(max_value>=min_value), + CHECK(min_value>=0) ); CREATE TABLE IF NOT EXISTS skill_chip_secondary_options ( - skill_chip_set_id TEXT NOT NULL REFERENCES skill_chip_sets(id) ON DELETE CASCADE, - stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id), - PRIMARY KEY(skill_chip_set_id, stat_attribute_id) + stat_attribute_id TEXT PRIMARY KEY REFERENCES stat_attributes(id) +); +CREATE TABLE IF NOT EXISTS skill_chip_secondary_quality_values ( + stat_attribute_id TEXT NOT NULL, + quality TEXT NOT NULL CHECK(quality IN ('Specialized','Superior','High-end')), + min_value REAL NOT NULL, + max_value REAL NOT NULL, + PRIMARY KEY(stat_attribute_id,quality), + FOREIGN KEY(stat_attribute_id) REFERENCES skill_chip_secondary_options(stat_attribute_id) ON DELETE CASCADE, + CHECK(max_value>=min_value), + CHECK(min_value>=0) ); CREATE TABLE IF NOT EXISTS skill_chip_talents ( id TEXT PRIMARY KEY, @@ -1232,19 +1246,21 @@ CREATE TABLE IF NOT EXISTS weapon_mod_quality_values ( CHECK(primary_level_20_max>=primary_level_1_max) ); CREATE TABLE IF NOT EXISTS weapon_mod_secondary_options ( - weapon_mod_id TEXT NOT NULL REFERENCES weapon_mods(id) ON DELETE CASCADE, - stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id), - PRIMARY KEY(weapon_mod_id,stat_attribute_id) + stat_attribute_id TEXT PRIMARY KEY REFERENCES stat_attributes(id) ); CREATE TABLE IF NOT EXISTS weapon_mod_secondary_quality_values ( - weapon_mod_id TEXT NOT NULL, stat_attribute_id TEXT NOT NULL, - quality TEXT NOT NULL CHECK(quality IN ('Standard','Specialized','Superior','High-end')), - min_value REAL NOT NULL, - max_value REAL NOT NULL, - PRIMARY KEY(weapon_mod_id,stat_attribute_id,quality), - FOREIGN KEY(weapon_mod_id,stat_attribute_id) REFERENCES weapon_mod_secondary_options(weapon_mod_id,stat_attribute_id) ON DELETE CASCADE, - CHECK(max_value>=min_value) + quality TEXT NOT NULL CHECK(quality IN ('Specialized','Superior','High-end')), + level_1_min REAL NOT NULL, + level_1_max REAL NOT NULL, + level_20_min REAL NOT NULL, + level_20_max REAL NOT NULL, + PRIMARY KEY(stat_attribute_id,quality), + FOREIGN KEY(stat_attribute_id) REFERENCES weapon_mod_secondary_options(stat_attribute_id) ON DELETE CASCADE, + CHECK(level_1_max>=level_1_min), + CHECK(level_20_max>=level_20_min), + CHECK(level_20_min>=level_1_min), + CHECK(level_20_max>=level_1_max) ); CREATE TABLE IF NOT EXISTS weapon_mod_exotic_attributes ( id TEXT PRIMARY KEY, @@ -1292,19 +1308,21 @@ CREATE TABLE IF NOT EXISTS gear_mod_quality_values ( CHECK(primary_level_20_max>=primary_level_1_max) ); CREATE TABLE IF NOT EXISTS gear_mod_secondary_options ( - gear_mod_id TEXT NOT NULL REFERENCES gear_mods(id) ON DELETE CASCADE, - stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id), - PRIMARY KEY(gear_mod_id,stat_attribute_id) + stat_attribute_id TEXT PRIMARY KEY REFERENCES stat_attributes(id) ); CREATE TABLE IF NOT EXISTS gear_mod_secondary_quality_values ( - gear_mod_id TEXT NOT NULL, stat_attribute_id TEXT NOT NULL, - quality TEXT NOT NULL CHECK(quality IN ('Standard','Specialized','Superior','High-end')), - min_value REAL NOT NULL, - max_value REAL NOT NULL, - PRIMARY KEY(gear_mod_id,stat_attribute_id,quality), - FOREIGN KEY(gear_mod_id,stat_attribute_id) REFERENCES gear_mod_secondary_options(gear_mod_id,stat_attribute_id) ON DELETE CASCADE, - CHECK(max_value>=min_value) + quality TEXT NOT NULL CHECK(quality IN ('Specialized','Superior','High-end')), + level_1_min REAL NOT NULL, + level_1_max REAL NOT NULL, + level_20_min REAL NOT NULL, + level_20_max REAL NOT NULL, + PRIMARY KEY(stat_attribute_id,quality), + FOREIGN KEY(stat_attribute_id) REFERENCES gear_mod_secondary_options(stat_attribute_id) ON DELETE CASCADE, + CHECK(level_1_max>=level_1_min), + CHECK(level_20_max>=level_20_min), + CHECK(level_20_min>=level_1_min), + CHECK(level_20_max>=level_1_max) ); CREATE TABLE IF NOT EXISTS specialization_progression_config ( id TEXT PRIMARY KEY CHECK(id='default'), @@ -1628,7 +1646,7 @@ INSERT OR IGNORE INTO calculation_stages(id,revision_id,stage_key,label,operator INSERT OR IGNORE INTO calculation_stages(id,revision_id,stage_key,label,operator,display_order,locked_position) VALUES('calculation-stage-cycle-1','calculation-revision-1','firing_cycle','Firing cycle','firing_cycle',9,'last'); CREATE TABLE IF NOT EXISTS game_data_icons ( - entity_type TEXT NOT NULL CHECK(entity_type IN ('talent', 'brand_set', 'os_protocol', 'skill_chip', 'specialization', 'specialization_ability', 'specialization_node')), + entity_type TEXT NOT NULL CHECK(entity_type IN ('talent', 'brand_set', 'os_protocol', 'skill_chip', 'specialization', 'specialization_ability', 'specialization_node', 'weapon_mod_slot')), entity_id TEXT NOT NULL, storage_key TEXT NOT NULL UNIQUE, byte_size INTEGER NOT NULL CHECK(byte_size > 0), diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index 88ecf58..687ea9b 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -1572,13 +1572,42 @@ an icon in every option. ### Skill Chip data console -Skill Chip editing uses the dense ISAC staged-definition pattern: identity and -icon, 2-piece numeric bonus, mutually exclusive primary and secondary roll -pools, then 3-piece talent. Superior blue and High-end yellow identify the -supported qualities; reserve orange for actions and the current interactive -state. A four-step segmented preview shows the linear zero-to-three High-end -interpolation without duplicate values. At narrow widths, stack roll-pool and -talent columns, use a two-by-two preview grid, and retain 44-pixel targets. +Place two shared configuration cards above the Skill Chip definition registry. +The `SHARED PRIMARY PROFILE` card contains the fixed Skill Duration, Skill +Radius, Skill Cooldown Recovery, and Skill Intensity rows, with direct minimum +and maximum fields for Standard, Specialized, Superior, and High-end. Its +summary shows the configured count for each quality and the `NOT YET DEFINED` +calculation state. The `SHARED SECONDARY POOL` card contains repeatable +searchable Attribute rows with direct minimum and maximum fields for +Specialized, Superior, and High-end. Its summary shows each quality's fixed +capacity and the shared option count. Each card opens its own editor and closes +that editor after a successful save. Validation keeps the affected editor open +with its values intact. Successful saves append +`game_data/skill_chip_primary_profile.updated` or +`game_data/skill_chip_secondary_pool.updated`. + +State the fixed secondary capacities in the pool card and editor: Standard zero, +Specialized one, Superior two, and High-end two. Skill Chip fields never show an +item-level control or level-based preview. Quality labels use the global green, +blue, purple, and yellow treatments; orange remains reserved for actions and +selection. On narrow screens, profile summaries stack, endpoint matrices scroll +inside their cards, and actions retain 44-pixel targets. + +Definition editors follow the dense staged pattern: ability assignment, identity +and icon, one per-set 2-piece bonus, then the optional 3-piece Talent. Place the +Talent assignment control before one disclosure that contains the entire Talent +workspace: identity, description, quality-mix preview, and +`MechanicProgramEditor`. The disclosure summary shows the configured Talent name +or `TALENT WORKSPACE`, the quality-scaled outcome count, and an explicit +`EXPAND` or `COLLAPSE` action. Existing Talents start collapsed when an operator +opens a definition. Assigning a Talent or receiving a Talent validation error +opens the workspace. Native disclosure semantics expose the expanded state, keep +draft state mounted while collapsed, and provide a 44-pixel mobile target. + +Every equipped chip quality counts toward the two-piece threshold. The set-bonus +preview uses four steps for zero through three equipped High-end chips and the +formula `minimum + (maximum − minimum) × h / 3`; only High-end chips increase +`h`. Do not duplicate calculated values as editable fields. ### ISAC record identity controls @@ -1808,6 +1837,15 @@ same `weaponBaseRange` curve as the builder; never label the configured base endpoints as level-50 values. Weapon Talent 1 and Talent 2 pools use separate clickable icon rails and the standard Talent quick view. +Show each weapon's named Mod slots and slot pictograms between its performance +facts and Talent pools. Native T0 weapons display the complete T1/T2 slot set. +Mark the slot that is absent at T0 with the blue information state and an +explicit `T1+` label so the unlock does not rely on color alone. Other slots use +the neutral telemetry treatment. If ISAC has no slot data, show +`MOD SLOT DATA NOT CONFIGURED`. Do not show the weapon-category bonus count as +`BASE BONUSES`; that identical count does not help players compare weapon +definitions. + Record cards may end with contextual actions that connect related public registries. Actions use the compact orange tactical-button treatment and encode their destination filter in the URL. Brand cards provide a “find Gear” action @@ -1970,18 +2008,26 @@ mode with weapon icons and retained accessible names. Show configured versus required slot counts in text. Disabled tiers remain visibly unavailable and must not submit slot assignments. -Quality-scaled registry editors use a compact matrix when the same numeric shape -repeats across all four quality levels. Apply global quality colors only to the -quality labels, retain textual quality names, and keep numeric fields neutral. -On narrow screens the matrix may scroll horizontally while its surrounding -editor and previews stack. +Regular Weapon Mod definitions use a compact Level 1 and Level 20 primary-range +matrix for Standard, Specialized, Superior, and High-end quality. Apply global +quality colors only to the quality labels, retain textual quality names, and +keep numeric fields neutral. On narrow screens the matrix may scroll +horizontally while its surrounding editor and previews stack. Primary core type +remains builder-selected and appears as the three always-available core icons +rather than a persisted ISAC choice. Individual regular Weapon Mod editors also +retain triggerless Mod Effects with one or two Attribute references and fixed +per-quality values. -When two item slots draw from the same canonical option pool, edit that pool -once rather than rendering two duplicated selectors. Weapon Mods use a -repeatable secondary-attribute row: one searchable attribute reference plus four -quality range pairs. Show that the builder selects two distinct options from the -pool. Primary core type is likewise builder-selected and is displayed as the -three always-available core icons rather than a persisted ISAC choice. +Configure one global secondary Attribute pool above the Weapon Mod registry. +Each repeatable row contains one searchable Attribute reference and Level 1 and +Level 20 minimum and maximum fields for Specialized, Superior, and High-end. +Values between those endpoints use linear interpolation. Do not render Standard +range fields because Standard has no secondary slot. State the fixed capacity in +the pool editor and builder: Standard zero, Specialized one, Superior two, and +High-end two. The builder requires distinct selections. Individual Weapon Mod +editors must not repeat or submit pool data. A successful pool save uses the +standard shared-profile lifecycle and creates the dedicated +`game_data/weapon_mod_secondary_pool.updated` audit event. Exotic Weapon Mod status is a separate single-icon toggle using the exotic icon and the standard pressed, focus-visible, and selected states. Selecting it must @@ -1993,12 +2039,20 @@ state panel visible so the change in data semantics is unmistakable. ### Gear Mod registry -Gear Mods reuse the compact quality matrix, level preview, and pooled -secondary-attribute rows established by Weapon Mods. Do not render exotic, +Gear Mods reuse the compact Level 1 and Level 20 primary-range matrix and level +preview established by regular Weapon Mods. Do not render exotic, weapon-category, slot-type, or Mod Effect controls in this simpler editor. Present the five primary options as an always-visible informational band because -the actual primary is selected on the build instance, not persisted on the -canonical definition. +the build instance selects the primary; the canonical definition does not store +that selection. + +Configure the Gear Mod family’s global secondary Attribute pool above the +registry, separate from individual Gear Mod editors. Use the same three-quality +endpoint rows, linear interpolation, and distinct-selection rule as the Weapon +Mod pool. Capacity is zero for Standard, one for Specialized, and two each for +Superior and High-end. Individual Gear Mod edits must not repeat or submit this +pool. A successful pool save creates the dedicated +`game_data/gear_mod_secondary_pool.updated` audit event. Use explicit textual tier capacity wherever Gear tier is summarized: `1 MOD SLOT` for T0 and `2 MOD SLOTS` for T1/T2. On narrow screens the identity @@ -2325,6 +2379,16 @@ applied to the stored value. Restoration outcomes also expose an `INSTANT` or field and describes the configured value as the total delivered across that duration; switching back to instant clears the duration. +Target-scoped direct-damage outcomes offer `HIT-TARGET EXPLOSION` in the Area +Targeting control. Selecting it creates an event-target-centered enemy area with +a five-meter starting radius, reveals the required radius field, and shows a +text readout for the center, valid-target policy, inclusive boundary, and +runtime evidence requirement. Only `ON EVENT` and `AFTER EVENT` remain available +for this mode; selecting it from another phase moves the outcome to +`AFTER EVENT`. Changing the outcome kind or target scope clears an incompatible +area definition. Keep `ENEMIES IN RADIUS · LEGACY` visible for saved descriptive +radius metadata, but do not present that mode as calculated explosion fan-out. + Action Family recipes are optional authoring starters. Present the recipe catalog as a searchable assignment with only the parameters required by the selected family. `ADD RECIPE BRANCH` copies typed branches into the current @@ -2511,10 +2575,11 @@ conditional Talent outcomes, weapon and Skill DPS, encounter assumptions, activation profiles, rotations, and simulation behind explicit `COMING SOON` panels. -Loadout schema version 4 retains `maximum`, `custom`, conditional state, -encounter-assumption, and per-ability activation fields for compatibility. The -current player UI ignores those fields. When player simulation ships, one -required two-option `SegmentedChoice` will use these visible labels: +Loadout schema version 5 retains `maximum`, `custom`, conditional state, +encounter-assumption, and per-ability activation fields. Version 4 is obsolete +prototype data rather than a compatibility format. The current player UI ignores +the simulation fields. When player simulation ships, one required two-option +`SegmentedChoice` will use these visible labels: - `MAXIMUM POTENTIAL` calculates a configured ceiling. It applies conditional effects at peak state and treats selected abilities as firing on cooldown @@ -2566,7 +2631,11 @@ Focus sets the primary Attribute, progression tree, and three ordered abilities. Its identity combines Specialization artwork and the primary Attribute mark. Each ability panel contains three fixed Skill Chip positions. A chip picker shows definitions assigned to that ability and excludes definitions assigned -elsewhere. +elsewhere. Skill Chips have no item-level control. The editor uses the shared +quality profiles for direct rolls. It labels the four ability-scoped primary +values `NOT YET DEFINED` and excludes them from current Agent and combat totals. +Secondary capacity is Standard zero, Specialized one, Superior two, and High-end +two; each equipped chip uses distinct selections from the shared pool. The focus tree groups nodes by row and uses segmented rank controls. Disabled ranks remain visible with their lock state. The builder permits a row after the @@ -2581,11 +2650,11 @@ enabled ability. Future Skill result cards will show activation count, single-target DPS, total-target DPS, and a combined summary. They will state that Skills can critically hit and multi-shot but cannot headshot. -Saved loadouts store focus, node ranks, nine Skill Chips, conditional modes, +Version 5 loadouts store focus, node ranks, nine Skill Chips, conditional modes, encounter assumptions, and per-ability activation profiles. The builder accepts -only the current complete structure for compatibility. Current player pages -ignore the simulation fields. Invalid local drafts are discarded; invalid server -records are removed through a backed-up database migration. +only the current complete structure. Current player pages ignore the simulation +fields. The builder discards version 4 local drafts; a backed-up database +migration removes version 4 server records. ### Calculation revision controls @@ -2650,3 +2719,13 @@ asset and states that the change is permanent. For assigned icons, the confirmation names the owning entity type. ISAC clears that source icon assignment before it removes the shared file. General library uploads retain the `ICON` or `IMAGE` state. + +In the Weapon Mod registry, operators manage shared artwork for all eight +code-defined slots. The compact panel labels each slot `ICON REGISTERED` or +`ICON REQUIRED`. Selecting a slot opens the standard icon upload control. Keep +the slot name visible in ISAC after an operator uploads artwork. + +On public weapon cards, a configured slot chip shows only its uploaded icon. +Keep the slot name in screen-reader and hover text. Retain the visible `T1+` +marker and blue information treatment for slots unlocked at Tier 1. When artwork +is missing, show the slot name instead of an invented glyph. diff --git a/docs/architecture.md b/docs/architecture.md index 7f54bbd..4685962 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -431,11 +431,13 @@ reference. # OS Protocol progression -OS Protocols use dedicated normalized tables because their talent progression -has six calculated states (base plus five upgrades) rather than the shared -two-tier talent model. Each protocol owns one `os_protocol_primary_effects` -record and one `os_protocol_talents` record. Each protocol Talent owns its -mechanic program through the shared conditional-effect tables. +OS Protocols use dedicated normalized tables because an assigned Talent has six +calculated states: its base value plus five upgrades. Each protocol owns one +`os_protocol_primary_effects` record and may own one `os_protocol_talents` +record. The optional Talent owns its mechanic program through the shared +conditional-effect tables. ISAC keeps the Talent workspace collapsed until an +operator assigns or opens it, and removing an existing Talent requires explicit +confirmation. Dedicated profile tables normalize the three secondary attributes. One `os_protocol_secondary_profiles` record exists for each configured quality + @@ -460,6 +462,15 @@ compatible base weapon categories through `weapon_mod_compatible_categories`; eligibility requires both a category match and the slot to exist on the equipped weapon at its selected tier. +The eight code-defined slot types have stable UUIDs and one shared icon each. +Operators manage the artwork in the Weapon Mod registry through the game-data +icon pipeline. Each upload or removal appends a +`game_data/weapon_mod_slot_icon.updated` audit event, and contributors may send +the same action to Data Oversight. Public weapon cards use icon-only slot chips +when artwork exists, with the slot name available to screen readers and on +hover. Cards show the name when artwork is missing and retain the visible `T1+` +marker for slots unlocked at Tier 1. + Exotic Weapon Mods instead reference one `exotic_weapon_id`. They do not carry regular category compatibility. The builder treats them as permanent equipment and lets the user change only their level. They do not use quality profiles, @@ -469,16 +480,21 @@ values in `weapon_mod_exotic_attributes`; intermediate values use linear interpolation. All regular Weapon Mods permit Firepower, Toughness, or Engineering as a -builder-time primary choice. `weapon_mod_quality_values` stores the shared -level-1 and level-20 primary roll endpoints for each quality; primary values use -linear interpolation from levels 1–20. +builder-time primary choice. `weapon_mod_quality_values` stores each +definition's level-1 and level-20 primary roll endpoints for each quality; +primary values use linear interpolation from levels 1–20. -`weapon_mod_secondary_options` is the shared pool from which a build selects two -distinct secondary attributes. Every option owns a minimum and maximum at each -quality through `weapon_mod_secondary_quality_values`; secondary availability is -not tied to a numbered slot in canonical data. Mod Effects are triggerless, hold -one or two attribute references, and store fixed per-quality values in -`weapon_mod_effect_quality_values`. +`weapon_mod_secondary_options` stores one global secondary Attribute pool for +all regular Weapon Mods. Each Attribute has Level 1 and Level 20 minimum and +maximum ranges for Specialized, Superior, and High-end quality through +`weapon_mod_secondary_quality_values`; values at intermediate levels use linear +interpolation. Secondary capacity is fixed by quality: Standard has zero, +Specialized has one, Superior has two, and High-end has two. A build selects +distinct Attributes from the pool up to that capacity. Individual Weapon Mod +records do not own secondary options or ranges. Pool updates append a +`game_data/weapon_mod_secondary_pool.updated` audit event. Mod Effects remain +triggerless, hold one or two Attribute references, and store fixed per-quality +values in `weapon_mod_effect_quality_values`. ### Gear Mods and tier slot capacity @@ -488,42 +504,68 @@ Specialized, Superior, and High-end quality at Levels 1 and 20. Values between those endpoints use linear interpolation. A build instance selects one primary attribute from Firepower, Toughness, Engineering, Health, or Armor. -The two secondary slots draw distinct attributes from one shared option pool. -Each available secondary attribute stores its own minimum and maximum for all -four qualities. Gear tier determines capacity without per-item configuration: T0 -gear has one Gear Mod slot; T1 and T2 gear have two. +One global Gear Mod secondary Attribute pool serves every Gear Mod definition. +Each Attribute has Level 1 and Level 20 minimum and maximum ranges for +Specialized, Superior, and High-end quality through +`gear_mod_secondary_quality_values`; values at intermediate levels use linear +interpolation. Secondary capacity is fixed by quality: Standard has zero, +Specialized has one, Superior has two, and High-end has two. A build selects +distinct Attributes from the pool up to that capacity. Individual Gear Mod +records do not own secondary options or ranges. Gear tier still determines mod +slot capacity without per-item configuration: T0 gear has one Gear Mod slot; T1 +and T2 gear have two. -The `gear_mods`, `gear_mod_quality_values`, `gear_mod_secondary_options`, and -`gear_mod_secondary_quality_values` tables store canonical data. Successful -mutations use `gear_mod.created`, `gear_mod.updated`, `gear_mod.archived`, and -`gear_mod.unarchived` audit actions. Contributor submissions use the `gearmods` -proposal module and remain non-canonical until approved by Oversight. +The `gear_mods` and `gear_mod_quality_values` tables store individual definition +data. `gear_mod_secondary_options` and `gear_mod_secondary_quality_values` store +the family-wide pool. Pool updates append a +`game_data/gear_mod_secondary_pool.updated` audit event. Individual definition +mutations retain the `gear_mod.created`, `gear_mod.updated`, +`gear_mod.archived`, and `gear_mod.unarchived` audit actions. Contributor +submissions use the `gearmods` proposal module and remain non-canonical until +approved by Oversight. # Skill Chip quality scaling -Skill Chip sets support Superior and High-end chip instances. Each set owns one -2-piece bonus, distinct primary and secondary stat-option pools, and one 3-piece -Talent with a typed conditional rule. Primary and secondary options reference -active Attributes that define Skill Chip minimum and maximum roll values. A -configured chip rolls one primary and two distinct secondary stats from those -pools. +Each Skill Chip definition belongs to one specialization ability. Each set owns +one 2-piece bonus and may own one 3-piece Talent with a typed conditional rule. +ISAC keeps the complete Talent workspace, including its description and mechanic +program, inside one collapsible optional section. -Each active Skill Chip definition belongs to one active specialization ability -through `skill_chip_sets.specialization_ability_id`. An ability has capacity for -three active definitions. The save and restore actions enforce that capacity, -and the schema requires the foreign key for each row. A specialization focus is +All definitions use one fixed primary profile. The profile contains Skill +Duration, Skill Radius, Skill Cooldown Recovery, and Skill Intensity, with +direct minimum and maximum values for Standard, Specialized, Superior, and +High-end. Primary values apply to the assigned ability rather than the Agent. +Builder and shared Build pages label them `NOT YET DEFINED` and exclude them +from current Agent and combat totals. + +All definitions also use one shared secondary Attribute pool. Each pool entry +stores direct minimum and maximum values for Specialized, Superior, and +High-end. Standard chips have no secondary slot, Specialized chips have one, and +Superior and High-end chips have two. A configured chip selects distinct +secondary Attributes up to its quality's capacity. Skill Chips have no item +level, so neither profile uses level interpolation. + +Active definitions reference an active specialization ability through +`skill_chip_sets.specialization_ability_id`. An ability has capacity for three +active definitions. The save and restore actions enforce that capacity, and the +schema requires the foreign key for each row. A specialization focus is builder-ready only when each of its three assigned abilities has all three active Skill Chip definitions. Skill Chip mutations refresh readiness for focuses that reference both the previous and resulting ability. -Both set-bonus progression systems use the number of equipped High-end chips as -the interpolation step. For `h` High-end chips, where `h` is zero through three, -a configured minimum and maximum resolve as -`minimum + (maximum − minimum) × h / 3`. Zero High-end chips therefore produce -the minimum, while three produce the maximum. The editor stores endpoints and -previews all four states, preventing duplicated tier data from diverging. The -`game_data` audit stream captures Skill Chip mutations, owned relationships, and -safe icon metadata. +Every equipped quality counts toward the two-piece threshold. After the loadout +meets that threshold, only equipped High-end chips increase the interpolation +step. For `h` High-end chips, where `h` is zero through three, a configured +minimum and maximum resolve as `minimum + (maximum − minimum) × h / 3`. Zero +High-end chips resolve to the minimum, while three resolve to the maximum. The +editor stores endpoints and previews all four states. + +ISAC presents the fixed primary profile and secondary pool as two shared +registry/editor cards above the definition registry. Successful profile saves +append `game_data/skill_chip_primary_profile.updated` and +`game_data/skill_chip_secondary_pool.updated`, respectively. Definition +mutations continue to capture their owned bonus, optional Talent, ability +relationship, and safe icon metadata in the `game_data` audit stream. # Mechanic programs and loadout state @@ -577,7 +619,23 @@ state, source, movement, and nearby counts travel as typed event values. The event registry publishes authoritative field metadata where branches need a bounded contract. For those events, the mechanic editor derives its event-field controls from that metadata. For example, `weapon_hit.values.headshot` is a -boolean that distinguishes headshots from body shots. +boolean that distinguishes headshots from body shots. `enemy_killed` and +`assist` may carry `values.assignedAbilityActive`; a Skill Chip Talent uses that +boolean to restrict a kill or assist trigger to its assigned ability's active +period. `skill_deployed` and `signature_ability_deployed` may carry +`values.assignedAbilityDurationSeconds` to start assigned-ability state. The +runtime expires that state before it evaluates another event at the same +timestamp. + +`weapon_hit.values.aiming` records whether the Agent was aiming down sights when +the hit resolved. A producer omits the field when it lacks authoritative aiming +state; the runtime does not replace missing evidence with a scenario assumption. +Predicates compare this field explicitly with `true` or `false`; negation and +inequality cannot turn missing evidence into a match. A weapon-hit event may +also include an event-target-centered area sample. The bounded sample lists up +to 256 valid ally and enemy entity IDs with their distances from the hit target +and includes that target as an enemy at zero meters. Duplicate IDs, negative +distances, or a missing center target invalidate the evaluation. `extra_health_changed` is Agent-scoped. Normalized timeline producers emit it when the Agent receives an Extra Health grant, when the remaining amount @@ -612,7 +670,10 @@ The program graph uses three child tables: durations, intervals, or derived values. Mutations can set, add, consume, clear, derive, or transfer state. A mutation may bind a target-scoped pool to the current event target, event actor, or another typed entity selector. Each - target owns an independent value and expiry timer. + target owns an independent value and expiry timer. A clause may require active + assigned-ability state and carry `procChancePercent`. The runtime evaluates + that chance after predicates and cooldown checks, before reducers, mutations, + and outcomes. - `conditional_effect_outcomes` applies Attribute modifiers, direct damage, restoration, resource costs, or markers. Each outcome names its target scope, value basis, optional resource or Attribute, combat timing, optional state @@ -621,15 +682,37 @@ The program graph uses three child tables: required by the owning source. The common value models are static values, live Attribute values, percentage-normalized Attribute values, event-field values, reducer output, source-defined scenario values, and OS Protocol upgrade - scaling. Restoration outcomes may deliver their total immediately or + scaling. An outcome may carry `probabilityPercent`; each eligible outcome + rolls separately. Restoration outcomes may deliver their total immediately or continuously over an explicit duration. + Target-scoped direct damage may use `event_target_area` targeting for an + explosion around its event target. This targeting selects valid enemies and + stores a positive radius in meters. It is valid only for `on_event` or + `after_event` outcomes. The legacy `within_radius` shape remains descriptive + targeting metadata and does not select runtime entities. + The generic runtime in `src/lib/calculations/mechanic.ts` processes combat events in timestamp order. Predicates decide whether an event enters a clause. Reducers convert event streams into activations or derived values. The runtime applies state mutations, enforces pool bounds and expiry, emits outcomes in phase order, and records telemetry for each activation. +When `event_target_area` damage activates, the runtime requires the triggering +event's authoritative area sample. It includes valid enemies whose distance is +less than or equal to the configured radius, sorts them by entity ID, and emits +one equal damage outcome for each entity. The outcome chance resolves once +before fan-out. A missing sample invalidates the evaluation instead of producing +single-target damage or treating the area as empty. + +Custom evaluation uses a Mulberry32 stream seeded by `randomSeed`; an omitted +seed resolves from the stable mechanic program ID. The returned evaluation +reports the seed. Maximum mode resolves every nonzero clause or outcome chance +as successful. `extend_duration` changes only an active assigned ability's +remaining duration. `start_cooldown_on_max` writes an expiring Agent cooldown +pool only when an earlier mutation in the same clause transitions a capped stack +pool to its maximum. + Each write to an expiring pool refreshes its timer. When a pool expires, `on_exit` outcomes evaluate stack thresholds and per-stack expressions against the final value before reset. The runtime records the bound target on emitted @@ -793,17 +876,17 @@ applicable, default value, and best-case value. ISAC retains these definitions for continued mechanic authoring. Current Builder and shared Build pages do not render or evaluate them. Their calculation panels display `COMING SOON` instead. -Loadout schema version 4 stores one focus, selected tree ranks, nine Skill +Loadout schema version 5 stores one focus, selected tree ranks, nine Skill Chips, a unified conditional-effect state map, encounter assumptions, per-ability activation controls, and `calculationMode`. Runtime readers accept -the exact version 4 structure. The schema retains its simulation fields for -compatibility, but the current player UI ignores them. A backed-up prototype -migration removes drafts, saved builds, and calibration snapshots that use -incompatible payloads. +the exact version 5 structure. The schema retains its simulation fields, but the +current player UI ignores them. Version 4 is obsolete prototype data. A +backed-up prototype migration removes version 4 drafts, saved builds, and +calibration snapshots instead of translating them. ## Planned player calculation modes -Loadout v4 defines `maximum` and `custom` modes for the planned simulation UI: +Loadout v5 defines `maximum` and `custom` modes for the planned simulation UI: - Maximum mode supplies each scenario input's best-case value, selects peak conditional-effect state, and runs selected abilities on cooldown against @@ -925,11 +1008,12 @@ does not scale. # Game-data icon storage -Talents, brand sets, OS Protocols, and Skill Chips reference uploaded artwork -through the polymorphic `game_data_icons` metadata table. The shared server -pipeline accepts transparent SVG, PNG, WebP, and AVIF sources, rejects active -SVG content and external references, and uses Sharp’s native worker pool to -rasterize and normalize each accepted icon to a transparent 256 × 256 WebP. +Talents, brand sets, OS Protocols, Skill Chips, Specializations, and shared +Weapon Mod slots reference uploaded artwork through the polymorphic +`game_data_icons` metadata table. The shared server pipeline accepts transparent +SVG, PNG, WebP, and AVIF sources, rejects active SVG content and external +references, and uses Sharp’s native worker pool to rasterize and normalize each +accepted icon to a transparent 256 × 256 WebP. Normalized files live beneath the ignored `data/game-icons/` directory. The validated `/icons/game-data/[type]/[id]` endpoint serves them with immutable diff --git a/scripts/db-migrate.mjs b/scripts/db-migrate.mjs index bb31e48..ece67fe 100644 --- a/scripts/db-migrate.mjs +++ b/scripts/db-migrate.mjs @@ -91,9 +91,14 @@ const isWeaponSelection = (value) => const isChipSelection = (value) => hasExactKeys(value, ['setId', 'quality', 'primary', 'secondary']) && typeof value.setId === 'string' && - (value.quality === 'Superior' || value.quality === 'High-end') && + ['Standard', 'Specialized', 'Superior', 'High-end'].includes(value.quality) && isRoll(value.primary) && - isRollPair(value.secondary); + Array.isArray(value.secondary) && + value.secondary.length === + ({ Standard: 0, Specialized: 1, Superior: 2, 'High-end': 2 }[ + value.quality + ] ?? -1) && + value.secondary.every(isRoll); const isProtocolSelection = (value) => hasExactKeys(value, ['itemId', 'level', 'talentUpgrades']) && typeof value.itemId === 'string' && @@ -185,7 +190,7 @@ const isCurrentLoadoutSnapshot = (raw) => { 'skillActivations', 'specialization', ]) && - value.version === 4 && + value.version === 5 && typeof value.buildName === 'string' && ['maximum', 'custom'].includes(value.calculationMode) && hasExactKeys(value.gear, gearSlots) && @@ -2392,6 +2397,32 @@ if ( ); } +const weaponModSlotIconDefinition = await ( + await database.prepare( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='game_data_icons'", + ) +).get(); +if ( + typeof weaponModSlotIconDefinition?.sql === 'string' && + !weaponModSlotIconDefinition.sql.includes("'weapon_mod_slot'") +) { + console.log('Adding shared Weapon Mod slot icon storage support.'); + await database.exec(`CREATE TABLE game_data_icons_weapon_mod_slots ( + entity_type TEXT NOT NULL CHECK(entity_type IN ('talent', 'brand_set', 'os_protocol', 'skill_chip', 'specialization', 'specialization_ability', 'specialization_node', 'weapon_mod_slot')), + entity_id TEXT NOT NULL, + storage_key TEXT NOT NULL UNIQUE, + byte_size INTEGER NOT NULL CHECK(byte_size > 0), + updated_at TEXT NOT NULL, + PRIMARY KEY(entity_type, entity_id) + )`); + await database.exec(`INSERT INTO game_data_icons_weapon_mod_slots + SELECT entity_type,entity_id,storage_key,byte_size,updated_at FROM game_data_icons`); + await database.exec('DROP TABLE game_data_icons'); + await database.exec( + 'ALTER TABLE game_data_icons_weapon_mod_slots RENAME TO game_data_icons', + ); +} + const managedImageColumns = await ( await database.prepare("PRAGMA table_info('managed_images')") ).all(); @@ -2433,6 +2464,7 @@ const iconLibraryCategory = { specialization: 'Specializations', specialization_ability: 'Specializations', specialization_node: 'Specializations', + weapon_mod_slot: 'Weapon Mods', }; const iconLibraryLabel = { talent: 'Talent', @@ -2442,6 +2474,17 @@ const iconLibraryLabel = { specialization: 'Specialization', specialization_ability: 'Specialization Ability', specialization_node: 'Specialization Tree Node', + weapon_mod_slot: 'Weapon Mod Slot', +}; +const weaponModSlotNames = { + '28ffc3b1-5d98-4273-a9af-67dbff3e5c76': 'Optics', + 'c4598f51-2cb0-4a20-a3a7-6d8983a26598': 'Muzzle', + '85ca3493-1871-4d22-950a-ed718fd547e0': 'Underbarrel', + '4d809a35-8285-4bff-be98-a0effe1d257e': 'Magazine', + '0e157cfe-975c-441d-8f49-72983161e7b1': 'Barrel', + '551fd7c8-3e5b-4c89-8437-9b901e89e8aa': 'Upper Receiver', + '72573367-6e91-4312-9cc3-710ede4f078e': 'Lower Receiver', + '8dda5edb-6079-4a23-a556-cd9be4521c70': 'Trigger', }; const normalizeLibraryTags = (values) => [ @@ -2532,6 +2575,13 @@ const linkedIconMetadata = async (entityType, entityId) => { ], }; } + case 'weapon_mod_slot': { + const name = weaponModSlotNames[entityId]; + return { + name: name ? `${name} Slot` : null, + tags: ['weapon mod', 'slot', name], + }; + } default: return { name: null, tags: [] }; } @@ -2912,6 +2962,267 @@ if ( await database.exec('PRAGMA foreign_keys=ON'); } +for (const kind of ['weapon', 'gear']) { + const optionTable = `${kind}_mod_secondary_options`; + const valueTable = `${kind}_mod_secondary_quality_values`; + const optionColumns = await ( + await database.prepare(`PRAGMA table_info('${optionTable}')`) + ).all(); + if (!optionColumns.some((column) => column.name === `${kind}_mod_id`)) + continue; + + const conflictingRanges = await ( + await database.prepare(`SELECT stat_attribute_id,quality, + COUNT(DISTINCT CAST(min_value AS TEXT)||'/'||CAST(max_value AS TEXT)) AS variants + FROM ${valueTable} + WHERE quality IN ('Specialized','Superior','High-end') + GROUP BY stat_attribute_id,quality HAVING variants>1`) + ).all(); + if (conflictingRanges.length) { + throw new Error( + `Cannot create the shared ${kind} Mod secondary pool because existing per-Mod ranges disagree. Reconcile ${conflictingRanges.map((row) => `${row.stat_attribute_id}/${row.quality}`).join(', ')} first.`, + ); + } + + const incompleteOptions = await ( + await database.prepare(`SELECT options.stat_attribute_id, + COUNT(DISTINCT CASE WHEN values_row.quality IN ('Specialized','Superior','High-end') THEN values_row.quality END) AS qualities + FROM ${optionTable} options + LEFT JOIN ${valueTable} values_row ON values_row.${kind}_mod_id=options.${kind}_mod_id + AND values_row.stat_attribute_id=options.stat_attribute_id + GROUP BY options.${kind}_mod_id,options.stat_attribute_id HAVING qualities<>3`) + ).all(); + if (incompleteOptions.length) { + throw new Error( + `Cannot create the shared ${kind} Mod secondary pool because ${incompleteOptions.map((row) => row.stat_attribute_id).join(', ')} does not have all applicable quality ranges.`, + ); + } + + console.log( + `Converting ${kind} Mod secondary Attributes to one shared level-scaled pool.`, + ); + await database.exec('PRAGMA foreign_keys=OFF'); + await database.exec('BEGIN'); + try { + await database.exec(`CREATE TABLE ${optionTable}_shared ( + stat_attribute_id TEXT PRIMARY KEY REFERENCES stat_attributes(id) + )`); + await database.exec(`CREATE TABLE ${valueTable}_shared ( + stat_attribute_id TEXT NOT NULL, + quality TEXT NOT NULL CHECK(quality IN ('Specialized','Superior','High-end')), + level_1_min REAL NOT NULL, + level_1_max REAL NOT NULL, + level_20_min REAL NOT NULL, + level_20_max REAL NOT NULL, + PRIMARY KEY(stat_attribute_id,quality), + FOREIGN KEY(stat_attribute_id) REFERENCES ${optionTable}_shared(stat_attribute_id) ON DELETE CASCADE, + CHECK(level_1_max>=level_1_min), + CHECK(level_20_max>=level_20_min), + CHECK(level_20_min>=level_1_min), + CHECK(level_20_max>=level_1_max) + )`); + await database.exec(`INSERT INTO ${optionTable}_shared(stat_attribute_id) + SELECT DISTINCT stat_attribute_id FROM ${optionTable}`); + await database.exec(`INSERT INTO ${valueTable}_shared( + stat_attribute_id,quality,level_1_min,level_1_max,level_20_min,level_20_max + ) SELECT stat_attribute_id,quality,MIN(min_value),MAX(max_value),MIN(min_value),MAX(max_value) + FROM ${valueTable} + WHERE quality IN ('Specialized','Superior','High-end') + GROUP BY stat_attribute_id,quality`); + await database.exec(`DROP TABLE ${valueTable}`); + await database.exec(`DROP TABLE ${optionTable}`); + await database.exec( + `ALTER TABLE ${optionTable}_shared RENAME TO ${optionTable}`, + ); + await database.exec( + `ALTER TABLE ${valueTable}_shared RENAME TO ${valueTable}`, + ); + await database.exec('COMMIT'); + } catch (error) { + await database.exec('ROLLBACK'); + throw error; + } finally { + await database.exec('PRAGMA foreign_keys=ON'); + } +} + +const skillChipPrimaryOptionColumns = await ( + await database.prepare("PRAGMA table_info('skill_chip_primary_options')") +).all(); +if ( + skillChipPrimaryOptionColumns.some( + (column) => column.name === 'skill_chip_set_id', + ) +) { + const fixedSkillChipAttributes = await ( + await database.prepare(`SELECT COUNT(*) AS total, + SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active + FROM stat_attributes + WHERE name IN ('Skill Duration','Skill Radius','Skill Cooldown Recovery','Skill Intensity')`) + ).get(); + if ( + Number(fixedSkillChipAttributes?.total) !== 4 || + Number(fixedSkillChipAttributes?.active) !== 4 + ) + throw new Error( + 'Skill Chip profile migration requires exactly four active fixed primary Attributes.', + ); + console.log( + 'Converting Skill Chip primary and secondary options to shared quality profiles.', + ); + await database.exec('PRAGMA foreign_keys=OFF'); + await database.exec('BEGIN'); + try { + await database.exec( + 'DROP TABLE IF EXISTS skill_chip_primary_quality_values', + ); + await database.exec( + 'DROP TABLE IF EXISTS skill_chip_secondary_quality_values', + ); + await database.exec(`CREATE TABLE skill_chip_primary_options_shared ( + stat_attribute_id TEXT PRIMARY KEY REFERENCES stat_attributes(id) + )`); + await database.exec(`CREATE TABLE skill_chip_primary_quality_values_shared ( + stat_attribute_id TEXT NOT NULL, + quality TEXT NOT NULL CHECK(quality IN ('Standard','Specialized','Superior','High-end')), + min_value REAL NOT NULL, + max_value REAL NOT NULL, + PRIMARY KEY(stat_attribute_id,quality), + FOREIGN KEY(stat_attribute_id) REFERENCES skill_chip_primary_options_shared(stat_attribute_id) ON DELETE CASCADE, + CHECK(max_value>=min_value), + CHECK(min_value>=0) + )`); + await database.exec(`CREATE TABLE skill_chip_secondary_options_shared ( + stat_attribute_id TEXT PRIMARY KEY REFERENCES stat_attributes(id) + )`); + await database.exec(`CREATE TABLE skill_chip_secondary_quality_values_shared ( + stat_attribute_id TEXT NOT NULL, + quality TEXT NOT NULL CHECK(quality IN ('Specialized','Superior','High-end')), + min_value REAL NOT NULL, + max_value REAL NOT NULL, + PRIMARY KEY(stat_attribute_id,quality), + FOREIGN KEY(stat_attribute_id) REFERENCES skill_chip_secondary_options_shared(stat_attribute_id) ON DELETE CASCADE, + CHECK(max_value>=min_value), + CHECK(min_value>=0) + )`); + await database.exec(`INSERT INTO skill_chip_primary_options_shared(stat_attribute_id) + SELECT id FROM stat_attributes + WHERE name IN ('Skill Duration','Skill Radius','Skill Cooldown Recovery','Skill Intensity') + AND active=1`); + await database.exec(`INSERT INTO skill_chip_primary_quality_values_shared( + stat_attribute_id,quality,min_value,max_value + ) SELECT attributes.id,qualities.quality,attributes.skill_chip_min,attributes.skill_chip_max + FROM stat_attributes attributes + CROSS JOIN ( + SELECT 'Standard' quality UNION ALL SELECT 'Specialized' + UNION ALL SELECT 'Superior' UNION ALL SELECT 'High-end' + ) qualities + WHERE attributes.name IN ('Skill Duration','Skill Radius','Skill Cooldown Recovery','Skill Intensity') + AND attributes.active=1 + AND attributes.skill_chip_min IS NOT NULL + AND attributes.skill_chip_max IS NOT NULL`); + await database.exec(`INSERT INTO skill_chip_secondary_options_shared(stat_attribute_id) + SELECT DISTINCT stat_attribute_id FROM skill_chip_secondary_options`); + await database.exec(`INSERT INTO skill_chip_secondary_quality_values_shared( + stat_attribute_id,quality,min_value,max_value + ) SELECT options.stat_attribute_id,qualities.quality,attributes.skill_chip_min,attributes.skill_chip_max + FROM (SELECT DISTINCT stat_attribute_id FROM skill_chip_secondary_options) options + JOIN stat_attributes attributes ON attributes.id=options.stat_attribute_id + CROSS JOIN ( + SELECT 'Specialized' quality UNION ALL SELECT 'Superior' UNION ALL SELECT 'High-end' + ) qualities + WHERE attributes.skill_chip_min IS NOT NULL + AND attributes.skill_chip_max IS NOT NULL`); + await database.exec('DROP TABLE skill_chip_primary_options'); + await database.exec('DROP TABLE skill_chip_secondary_options'); + await database.exec( + 'ALTER TABLE skill_chip_primary_options_shared RENAME TO skill_chip_primary_options', + ); + await database.exec( + 'ALTER TABLE skill_chip_primary_quality_values_shared RENAME TO skill_chip_primary_quality_values', + ); + await database.exec( + 'ALTER TABLE skill_chip_secondary_options_shared RENAME TO skill_chip_secondary_options', + ); + await database.exec( + 'ALTER TABLE skill_chip_secondary_quality_values_shared RENAME TO skill_chip_secondary_quality_values', + ); + await database.exec('COMMIT'); + } catch (error) { + await database.exec('ROLLBACK'); + throw error; + } finally { + await database.exec('PRAGMA foreign_keys=ON'); + } +} +await database.exec(`INSERT OR IGNORE INTO skill_chip_primary_options(stat_attribute_id) + SELECT id FROM stat_attributes + WHERE active=1 AND name IN ( + 'Skill Duration','Skill Radius','Skill Cooldown Recovery','Skill Intensity' + )`); + +const skillChipRangeAttributeColumns = await ( + await database.prepare("PRAGMA table_info('stat_attributes')") +).all(); +if ( + skillChipRangeAttributeColumns.some( + (column) => column.name === 'skill_chip_min', + ) +) { + console.log( + 'Removing obsolete Attribute-level Skill Chip ranges in favor of shared quality profiles.', + ); + await database.exec('PRAGMA foreign_keys=OFF'); + await database.exec('BEGIN'); + try { + await database.exec( + 'DROP INDEX IF EXISTS stat_attributes_name_unit_nocase_idx', + ); + await database.exec(`CREATE TABLE stat_attributes_current ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + type TEXT NOT NULL CHECK(type IN ('Offensive','Defensive')), + category TEXT NOT NULL CHECK(category IN ('Weapon','Skill','Character')), + calculation_type TEXT NOT NULL CHECK(calculation_type IN ('Additive','Multiplicative')), + unit TEXT NOT NULL DEFAULT '', + gear_min REAL, + gear_max REAL, + gear_reference_level INTEGER NOT NULL DEFAULT 50 CHECK(gear_reference_level BETWEEN 1 AND 50), + weapon_min REAL, + weapon_max REAL, + weapon_reference_level INTEGER NOT NULL DEFAULT 50 CHECK(weapon_reference_level BETWEEN 1 AND 50), + cap_value REAL, + active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + CHECK((gear_min IS NULL AND gear_max IS NULL) OR (gear_min IS NOT NULL AND gear_max IS NOT NULL AND gear_min<=gear_max)), + CHECK((weapon_min IS NULL AND weapon_max IS NULL) OR (weapon_min IS NOT NULL AND weapon_max IS NOT NULL AND weapon_min<=weapon_max)), + CHECK(cap_value IS NULL OR cap_value>=0) + )`); + await database.exec(`INSERT INTO stat_attributes_current( + id,name,description,type,category,calculation_type,unit, + gear_min,gear_max,gear_reference_level,weapon_min,weapon_max,weapon_reference_level, + cap_value,active,created_at,updated_at + ) SELECT id,name,description,type,category,calculation_type,unit, + gear_min,gear_max,gear_reference_level,weapon_min,weapon_max,weapon_reference_level, + cap_value,active,created_at,updated_at FROM stat_attributes`); + await database.exec('DROP TABLE stat_attributes'); + await database.exec( + 'ALTER TABLE stat_attributes_current RENAME TO stat_attributes', + ); + await database.exec( + 'CREATE UNIQUE INDEX stat_attributes_name_unit_nocase_idx ON stat_attributes(name COLLATE NOCASE,unit)', + ); + await database.exec('COMMIT'); + } catch (error) { + await database.exec('ROLLBACK'); + throw error; + } finally { + await database.exec('PRAGMA foreign_keys=ON'); + } +} + const buildColumns = await ( await database.prepare("PRAGMA table_info('builds')") ).all(); @@ -3305,6 +3616,19 @@ if ( ); } +const outcomeProbabilityColumns = await ( + await database.prepare("PRAGMA table_info('conditional_effect_outcomes')") +).all(); +if ( + !outcomeProbabilityColumns.some( + (column) => column.name === 'probability_percent', + ) +) { + await database.exec( + 'ALTER TABLE conditional_effect_outcomes ADD COLUMN probability_percent REAL CHECK(probability_percent IS NULL OR (probability_percent>=0 AND probability_percent<=100))', + ); +} + const calibrationCases = await ( await database.prepare( 'SELECT id,loadout_snapshot_json FROM calculation_calibration_cases', diff --git a/scripts/db-restore.mjs b/scripts/db-restore.mjs index 0b1481a..9acc3c1 100644 --- a/scripts/db-restore.mjs +++ b/scripts/db-restore.mjs @@ -44,21 +44,91 @@ for (const table of backup.tables) { } let restoredRows = 0; +const attributeTable = backup.tables.find( + (table) => table.name === 'stat_attributes', +); +const legacySkillChipProfiles = attributeTable?.rows.some( + (row) => 'skill_chip_min' in row || 'skill_chip_max' in row, +); +const legacySkillChipRanges = new Map( + (attributeTable?.rows ?? []).map((row) => [ + String(row.id), + { min: row.skill_chip_min, max: row.skill_chip_max }, + ]), +); +const restoredLegacySecondaries = new Set(); await database.exec('BEGIN'); try { for (const table of backup.tables) { for (const row of table.rows) { - const columns = Object.keys(row); + if ( + legacySkillChipProfiles && + table.name === 'skill_chip_primary_options' + ) + continue; + const restoredRow = { ...row }; + if (legacySkillChipProfiles && table.name === 'stat_attributes') { + delete restoredRow.skill_chip_min; + delete restoredRow.skill_chip_max; + } + if ( + legacySkillChipProfiles && + table.name === 'skill_chip_secondary_options' + ) { + delete restoredRow.skill_chip_set_id; + const statId = String(restoredRow.stat_attribute_id ?? ''); + if (restoredLegacySecondaries.has(statId)) continue; + restoredLegacySecondaries.add(statId); + } + const columns = Object.keys(restoredRow); if (!columns.length) continue; const columnSql = columns.map(quoteIdentifier).join(', '); const placeholders = columns.map(() => '?').join(', '); const insert = await database.prepare( `INSERT INTO ${quoteIdentifier(table.name)} (${columnSql}) VALUES (${placeholders})`, ); - await insert.run(...columns.map((column) => row[column])); + await insert.run(...columns.map((column) => restoredRow[column])); restoredRows++; } } + if (legacySkillChipProfiles) { + const primaryAttributes = await ( + await database.prepare(`SELECT id,active FROM stat_attributes WHERE name IN ( + 'Skill Duration','Skill Radius','Skill Cooldown Recovery','Skill Intensity' + )`) + ).all(); + if ( + primaryAttributes.length !== 4 || + primaryAttributes.some((attribute) => Number(attribute.active) !== 1) + ) + throw new Error( + 'Legacy restore requires exactly four active fixed Skill Chip primary Attributes.', + ); + for (const attribute of primaryAttributes) { + const statId = String(attribute.id); + await ( + await database.prepare( + 'INSERT INTO skill_chip_primary_options(stat_attribute_id) VALUES(?)', + ) + ).run(statId); + const range = legacySkillChipRanges.get(statId); + if (!range || range.min === null || range.max === null) continue; + for (const quality of ['Standard', 'Specialized', 'Superior', 'High-end']) + await ( + await database.prepare(`INSERT INTO skill_chip_primary_quality_values + (stat_attribute_id,quality,min_value,max_value) VALUES(?,?,?,?)`) + ).run(statId, quality, range.min, range.max); + } + for (const statId of restoredLegacySecondaries) { + const range = legacySkillChipRanges.get(statId); + if (!range || range.min === null || range.max === null) continue; + for (const quality of ['Specialized', 'Superior', 'High-end']) + await ( + await database.prepare(`INSERT INTO skill_chip_secondary_quality_values + (stat_attribute_id,quality,min_value,max_value) VALUES(?,?,?,?)`) + ).run(statId, quality, range.min, range.max); + } + } await database.exec('COMMIT'); } catch (error) { await database.exec('ROLLBACK'); diff --git a/scripts/import-resurgence-attributes.mjs b/scripts/import-resurgence-attributes.mjs index beaed7f..5a23732 100644 --- a/scripts/import-resurgence-attributes.mjs +++ b/scripts/import-resurgence-attributes.mjs @@ -98,8 +98,6 @@ try { weapon_min: record.weaponRange?.[0] ?? existing?.weapon_min ?? null, weapon_max: record.weaponRange?.[1] ?? existing?.weapon_max ?? null, weapon_reference_level: Number(existing?.weapon_reference_level ?? 50), - skill_chip_min: existing?.skill_chip_min ?? null, - skill_chip_max: existing?.skill_chip_max ?? null, cap_value: existing?.cap_value ?? null, active: 1, created_at: String(existing?.created_at ?? now), @@ -117,7 +115,7 @@ try { await ( await database.prepare(`UPDATE stat_attributes SET name=?,description=?,type=?,category=?,calculation_type=?,unit=?, - gear_min=?,gear_max=?,weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?, + gear_min=?,gear_max=?,weapon_min=?,weapon_max=?, cap_value=?,active=1,updated_at=? WHERE id=?`) ).run( next.name, @@ -130,8 +128,6 @@ try { next.gear_max, next.weapon_min, next.weapon_max, - next.skill_chip_min, - next.skill_chip_max, next.cap_value, now, id, @@ -142,8 +138,8 @@ try { await database.prepare(`INSERT INTO stat_attributes( id,name,description,type,category,calculation_type,unit, gear_min,gear_max,gear_reference_level,weapon_min,weapon_max,weapon_reference_level, - skill_chip_min,skill_chip_max,cap_value,active,created_at,updated_at - ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)`) + cap_value,active,created_at,updated_at + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)`) ).run( id, next.name, @@ -158,8 +154,6 @@ try { next.weapon_min, next.weapon_max, next.weapon_reference_level, - next.skill_chip_min, - next.skill_chip_max, next.cap_value, now, now, diff --git a/scripts/isac-data-export.mjs b/scripts/isac-data-export.mjs index 325881e..f43cfaa 100644 --- a/scripts/isac-data-export.mjs +++ b/scripts/isac-data-export.mjs @@ -67,7 +67,7 @@ for (const row of [ const snapshot = { format: 'shd-build-isac-data', - version: 2, + version: 4, createdAt: new Date().toISOString(), sourceDatabase: basename(databasePath), totalRows, diff --git a/scripts/isac-data-import.mjs b/scripts/isac-data-import.mjs index 48bd2c9..b26737c 100644 --- a/scripts/isac-data-import.mjs +++ b/scripts/isac-data-import.mjs @@ -22,7 +22,7 @@ const snapshotPath = resolve(snapshotArgument); const snapshot = JSON.parse(await readFile(snapshotPath, 'utf8')); if ( snapshot?.format !== 'shd-build-isac-data' || - snapshot?.version !== 2 || + snapshot?.version !== 4 || !Array.isArray(snapshot.tables) || !Array.isArray(snapshot.icons) ) { @@ -39,6 +39,152 @@ if ( 'The ISAC snapshot table manifest is incomplete or out of order.', ); } +for (const kind of ['weapon', 'gear']) { + const options = snapshot.tables.find( + (table) => table.name === `${kind}_mod_secondary_options`, + ).rows; + const values = snapshot.tables.find( + (table) => table.name === `${kind}_mod_secondary_quality_values`, + ).rows; + const optionIds = options.map((row) => String(row.stat_attribute_id ?? '')); + if ( + optionIds.some((id) => !id) || + new Set(optionIds).size !== optionIds.length || + (optionIds.length !== 0 && + (optionIds.length < 2 || optionIds.length > 50)) || + values.length !== optionIds.length * 3 + ) { + throw new Error( + `The ISAC snapshot contains an incomplete shared ${kind} Mod secondary pool.`, + ); + } + for (const statId of optionIds) { + const profile = values.filter( + (row) => String(row.stat_attribute_id) === statId, + ); + if ( + profile.length !== 3 || + ['Specialized', 'Superior', 'High-end'].some( + (quality) => !profile.some((row) => row.quality === quality), + ) || + profile.some((row) => { + const endpoints = [ + row.level_1_min, + row.level_1_max, + row.level_20_min, + row.level_20_max, + ]; + return ( + endpoints.some( + (value) => typeof value !== 'number' || !Number.isFinite(value), + ) || + endpoints[1] < endpoints[0] || + endpoints[2] < endpoints[0] || + endpoints[3] < endpoints[1] || + endpoints[3] < endpoints[2] + ); + }) + ) { + throw new Error( + `The ISAC snapshot contains an invalid shared ${kind} Mod secondary profile for ${statId}.`, + ); + } + } + if ( + values.some( + (row) => !optionIds.includes(String(row.stat_attribute_id ?? '')), + ) + ) { + throw new Error( + `The ISAC snapshot contains an orphaned shared ${kind} Mod secondary range.`, + ); + } +} +for (const [profileName, qualities, requiredOptions] of [ + ['primary', ['Standard', 'Specialized', 'Superior', 'High-end'], 4], + ['secondary', ['Specialized', 'Superior', 'High-end'], null], +]) { + const options = snapshot.tables.find( + (table) => table.name === `skill_chip_${profileName}_options`, + ).rows; + const values = snapshot.tables.find( + (table) => table.name === `skill_chip_${profileName}_quality_values`, + ).rows; + const optionIds = options.map((row) => String(row.stat_attribute_id ?? '')); + const validCardinality = requiredOptions + ? optionIds.length === requiredOptions + : optionIds.length <= 50; + if ( + !validCardinality || + optionIds.some((id) => !id) || + new Set(optionIds).size !== optionIds.length + ) { + throw new Error( + `The ISAC snapshot contains an incomplete shared Skill Chip ${profileName} profile.`, + ); + } + if (profileName === 'primary' && optionIds.length) { + const attributes = snapshot.tables.find( + (table) => table.name === 'stat_attributes', + ).rows; + const expectedIds = attributes + .filter( + (row) => + Number(row.active) === 1 && + [ + 'Skill Duration', + 'Skill Radius', + 'Skill Cooldown Recovery', + 'Skill Intensity', + ].includes(String(row.name)), + ) + .map((row) => String(row.id)) + .sort(); + if ( + expectedIds.length !== 4 || + optionIds + .slice() + .sort() + .some((id, index) => id !== expectedIds[index]) + ) + throw new Error( + 'The ISAC snapshot does not use the four fixed Skill Chip primary Attributes.', + ); + } + for (const statId of optionIds) { + const rows = values.filter( + (row) => String(row.stat_attribute_id ?? '') === statId, + ); + if (!rows.length) continue; + if ( + rows.length !== qualities.length || + qualities.some( + (quality) => !rows.some((row) => row.quality === quality), + ) || + rows.some( + (row) => + typeof row.min_value !== 'number' || + !Number.isFinite(row.min_value) || + typeof row.max_value !== 'number' || + !Number.isFinite(row.max_value) || + row.min_value < 0 || + row.max_value < row.min_value, + ) + ) { + throw new Error( + `The ISAC snapshot contains an invalid shared Skill Chip ${profileName} profile for ${statId}.`, + ); + } + } + if ( + values.some( + (row) => !optionIds.includes(String(row.stat_attribute_id ?? '')), + ) + ) + throw new Error( + `The ISAC snapshot contains an orphaned shared Skill Chip ${profileName} range.`, + ); +} for (const icon of snapshot.icons) { if ( typeof icon.storageKey !== 'string' || diff --git a/scripts/isac-data-tables.mjs b/scripts/isac-data-tables.mjs index ab436f3..458f44a 100644 --- a/scripts/isac-data-tables.mjs +++ b/scripts/isac-data-tables.mjs @@ -32,7 +32,9 @@ export const ISAC_DATA_TABLES = [ 'skill_chip_sets', 'skill_chip_two_piece_bonuses', 'skill_chip_primary_options', + 'skill_chip_primary_quality_values', 'skill_chip_secondary_options', + 'skill_chip_secondary_quality_values', 'skill_chip_talents', 'skill_chip_talent_effects', 'weapon_mods', diff --git a/scripts/managed-images-export.mjs b/scripts/managed-images-export.mjs index d60514a..4da0f13 100644 --- a/scripts/managed-images-export.mjs +++ b/scripts/managed-images-export.mjs @@ -23,7 +23,7 @@ const images = []; for (const row of rows) { if ( typeof row.storage_key !== 'string' || - !/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i.test( + !/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node|weapon_mod_slot)-[0-9a-f-]{36}\.webp$/i.test( row.storage_key, ) ) { diff --git a/scripts/managed-images-import.mjs b/scripts/managed-images-import.mjs index f9d306c..b25fc07 100644 --- a/scripts/managed-images-import.mjs +++ b/scripts/managed-images-import.mjs @@ -14,6 +14,19 @@ if (!bundleArgument || !process.argv.includes('--confirm-merge')) { process.exit(2); } const updateExisting = process.argv.includes('--update-existing'); +const gameDataRenderSourceTypes = new Set([ + 'weapon_render', + 'gear_render', + 'weapon_mod_render', + 'gear_mod_render', + 'talent_render', + 'brand_set_render', + 'os_protocol_render', + 'skill_chip_render', + 'specialization_render', + 'specialization_ability_render', + 'specialization_node_render', +]); const bundlePath = resolve(bundleArgument); const bundle = JSON.parse(await readFile(bundlePath, 'utf8')); if ( @@ -30,21 +43,21 @@ for (const image of bundle.images) { typeof image?.row?.id !== 'string' || !/^[0-9a-f-]{36}$/i.test(image.row.id) || typeof image.row.storage_key !== 'string' || - !/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i.test( + !/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node|weapon_mod_slot)-[0-9a-f-]{36}\.webp$/i.test( image.row.storage_key, ) || typeof image.contentBase64 !== 'string' ) { throw new Error('The bundle contains an invalid managed image.'); } - const isItemRender = ['weapon_render', 'gear_render'].includes( + const isGameDataRender = gameDataRenderSourceTypes.has( image.row.source_entity_type, ); if ( image.row.source_entity_type && (typeof image.row.source_entity_id !== 'string' || !/^[0-9a-f-]{36}$/i.test(image.row.source_entity_id) || - (isItemRender + (isGameDataRender ? !image.row.storage_key.startsWith('library-') : !image.row.storage_key.startsWith( `${image.row.source_entity_type}-`, diff --git a/src/app.css b/src/app.css index 81500f5..71a125d 100644 --- a/src/app.css +++ b/src/app.css @@ -4646,6 +4646,62 @@ a.secondary:active { font-size: var(--shd-type-telemetry); font-weight: bold; } +.selection-head > .row-action { + min-height: 34px; + display: inline-grid; + place-items: center; + justify-self: end; + padding: 0 12px; + border: 1px solid #4c3d26; + background: #17140f; + color: var(--shd-orange); + font-family: var(--shd-font-sans); + font-size: var(--shd-type-telemetry); + font-weight: 900; + line-height: 1; + letter-spacing: 0.65px; + cursor: pointer; + transition: + border-color 0.12s ease, + background 0.12s ease, + color 0.12s ease, + transform 0.08s ease; +} +.selection-head > .row-action:hover, +.selection-head > .row-action:focus-visible { + border-color: var(--shd-orange); + background: #211a0e; + color: var(--shd-orange-hover); +} +.selection-head > .row-action:focus-visible { + outline: 1px solid var(--shd-orange); + outline-offset: 2px; +} +.selection-head > .row-action:active:not(:disabled) { + transform: translateY(1px); + background: #120f0b; + filter: brightness(0.9); +} +.selection-head > .row-action:disabled { + border-color: var(--shd-border); + background: #111415; + color: var(--shd-text-muted); + cursor: not-allowed; + opacity: 0.58; +} +@media (max-width: 700px) { + .selection-head > .row-action { + min-height: 44px; + } +} +@media (prefers-reduced-motion: reduce) { + .selection-head > .row-action { + transition: none; + } + .selection-head > .row-action:active:not(:disabled) { + transform: none; + } +} .button-link { display: grid; place-items: center; @@ -12585,6 +12641,134 @@ a.secondary:active { opacity: 0.3; cursor: not-allowed; } +.mod-secondary-pool-registry { + margin: 10px 0; + padding: 10px; + border: 1px solid var(--shd-border); + border-left: 2px solid var(--quality-specialized); + background: #0d1011; +} +.mod-secondary-capacity { + display: grid; + grid-template-columns: repeat(5, minmax(120px, 1fr)); + margin-top: 8px; + border: 1px solid var(--shd-border); +} +.mod-secondary-capacity > span { + min-height: 58px; + display: grid; + grid-template-columns: 1fr auto; + grid-template-rows: 1fr 1fr; + align-items: center; + gap: 1px 8px; + padding: 8px; + border-left: 1px solid var(--shd-border); + background: #101314; +} +.mod-secondary-capacity > span:first-child { + border-left: 0; +} +.mod-secondary-capacity b, +.mod-secondary-capacity small { + font-size: var(--shd-type-telemetry); + letter-spacing: 0.55px; +} +.mod-secondary-capacity small { + color: var(--shd-text-muted); +} +.mod-secondary-capacity strong { + grid-row: 1/3; + grid-column: 2; + color: var(--shd-orange); + font-size: 24px; +} +.mod-secondary-capacity .pool-total { + border-left-color: #74552a; + background: #18150d; +} +.mod-secondary-pool-editor { + border-top-color: var(--quality-specialized); +} +.pool-editor-head { + margin-top: 12px; +} +.mod-secondary-pool-options { + display: grid; + gap: 7px; + margin-top: 7px; +} +.mod-secondary-pool-option { + display: grid; + grid-template-columns: 30px minmax(220px, 0.9fr) repeat( + 3, + minmax(285px, 1fr) + ) 30px; + align-items: stretch; + gap: 6px; + padding: 6px; + border: 1px solid var(--shd-border); + background: #101314; +} +.mod-secondary-pool-option > strong { + display: grid; + place-items: center; + background: #171b1c; + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); +} +.mod-secondary-quality-range { + display: grid; + grid-template-columns: 72px 1fr; + grid-template-rows: 1fr 1fr; + align-items: center; + gap: 4px 6px; + padding: 6px; + border: 1px solid var(--shd-border); +} +.mod-secondary-quality-range > b { + grid-row: 1/3; + font-size: var(--shd-type-telemetry); + letter-spacing: 0.5px; +} +.mod-secondary-quality-range label { + display: grid; + grid-template-columns: 54px 1fr; + align-items: center; + gap: 5px; +} +.mod-secondary-quality-range label > span { + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + font-weight: 900; +} +.mod-secondary-quality-range label > div { + display: grid; + grid-template-columns: 1fr 12px 1fr; + align-items: center; +} +.mod-secondary-quality-range input { + height: 30px; + margin: 0; + padding: 0 5px; +} +.mod-secondary-quality-range i { + color: #596164; + font-size: var(--shd-type-telemetry); + font-style: normal; + text-align: center; +} +.mod-secondary-pool-option > button { + align-self: end; + height: 32px; + border: 1px solid #563734; + background: #1d1212; + color: var(--shd-danger); + cursor: pointer; +} +.mod-secondary-pool-option > button:disabled { + opacity: 0.3; + cursor: not-allowed; +} @media (max-width: 1100px) { .weapon-mod-quality-row, .weapon-mod-quality-row.heading { @@ -12597,8 +12781,156 @@ a.secondary:active { .weapon-mod-secondary-options label:nth-of-type(4) { grid-column: auto; } + .mod-secondary-pool-options { + overflow-x: auto; + } + + .skill-chip-shared-config { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 12px; + } + + .skill-chip-shared-config .mod-secondary-pool-registry { + margin-bottom: 0; + } + + .skill-chip-profile-options { + display: grid; + gap: 8px; + overflow-x: auto; + } + + .skill-chip-profile-option { + min-width: 870px; + display: grid; + grid-template-columns: minmax(190px, 1.2fr) repeat(4, minmax(130px, 1fr)); + gap: 8px; + align-items: stretch; + padding: 8px; + border: 1px solid var(--shd-border); + background: #0e1112; + } + + .skill-chip-profile-option.selectable { + grid-template-columns: 32px minmax(210px, 1.2fr) repeat( + 3, + minmax(130px, 1fr) + ) 34px; + } + + .skill-chip-profile-option > div:first-child, + .skill-chip-profile-option > label { + min-width: 0; + display: grid; + align-content: center; + gap: 6px; + padding: 8px; + border: 1px solid #292f30; + background: #121617; + } + + .skill-chip-profile-option > div:first-child strong, + .skill-chip-profile-option > div:first-child small, + .skill-chip-profile-option > label > b { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .skill-chip-profile-option > div:first-child small, + .skill-chip-profile-option > label > b { + font-size: var(--shd-type-micro); + letter-spacing: 0.08em; + } + + .skill-chip-profile-option > label > span { + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: 5px; + align-items: center; + } + + .skill-chip-profile-option input { + width: 100%; + min-width: 0; + padding: 8px 6px; + border: 1px solid #353c3d; + background: #090c0d; + color: var(--shd-text); + font: inherit; + } + + .skill-chip-profile-option > strong { + align-self: center; + color: var(--shd-orange); + font-size: var(--shd-type-telemetry); + text-align: center; + } + + .skill-chip-profile-option > button:last-child { + border: 1px solid #553128; + background: #1a1110; + color: var(--shd-danger); + cursor: pointer; + } + + .skill-chip-profile-option > button:last-child:disabled { + opacity: 0.3; + cursor: not-allowed; + } + + .skill-chip-profile-option input:focus-visible, + .skill-chip-profile-option > button:last-child:focus-visible { + outline: 2px solid var(--shd-orange); + outline-offset: 2px; + } + + .skill-chip-talent-disclosure, + .protocol-talent-disclosure { + margin-top: 10px; + } + + .skill-chip-talent-removal, + .protocol-talent-removal { + display: flex; + align-items: center; + gap: 8px; + margin-top: 10px; + padding: 10px; + border: 1px solid #57332b; + background: #1a1110; + color: var(--shd-danger); + font-size: var(--shd-type-telemetry); + font-weight: 800; + letter-spacing: 0.06em; + } + + @media (max-width: 900px) { + .skill-chip-shared-config { + grid-template-columns: 1fr; + } + } + .mod-secondary-pool-option { + min-width: 1160px; + } } @media (max-width: 700px) { + .mod-secondary-capacity { + grid-template-columns: 1fr 1fr; + } + .mod-secondary-capacity > span, + .mod-secondary-capacity > span:first-child { + border-top: 1px solid var(--shd-border); + border-left: 1px solid var(--shd-border); + } + .mod-secondary-capacity > span:nth-child(odd) { + border-left: 0; + } + .mod-secondary-capacity .pool-total { + grid-column: 1/-1; + } .weapon-mod-secondary-options > div { grid-template-columns: 30px 1fr; } @@ -13088,6 +13420,18 @@ a.secondary:active { border-top: 2px solid #404748; background: #111415; } +.database-item-card.protocol-quality-standard { + border-top-color: var(--quality-standard); +} +.database-item-card.protocol-quality-specialized { + border-top-color: var(--quality-specialized); +} +.database-item-card.protocol-quality-superior { + border-top-color: var(--quality-superior); +} +.database-item-card.protocol-quality-highend { + border-top-color: var(--quality-high-end); +} .database-item-card > header { height: auto; min-height: 78px; @@ -13193,6 +13537,68 @@ a.secondary:active { font-size: var(--shd-type-telemetry); line-height: 1.35; } +.database-weapon-mod-slots { + display: grid; + grid-template-columns: 110px minmax(0, 1fr); + align-items: center; + gap: 9px; + margin: 7px 12px 0; + padding: 8px 0; +} +.database-weapon-mod-slots > div { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} +.database-weapon-mod-slots > div span, +.database-weapon-mod-slots > p { + color: #626a6c; + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.65px; +} +.database-weapon-mod-slots ul { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin: 0; + padding: 0; + list-style: none; +} +.database-weapon-mod-slots li { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 27px; + padding: 4px 7px; + border: 1px solid #383f40; + background: #0d1011; + color: #aeb4b5; + font-size: var(--shd-type-telemetry); + font-weight: 800; +} +.database-weapon-mod-slots li.has-slot-icon { + min-width: 34px; + justify-content: center; + padding: 4px; +} +.database-weapon-mod-slots li.upgrade-slot { + border-color: #315b6a; + background: #0b171c; + color: var(--shd-info); +} +.database-weapon-mod-slots li small { + padding-left: 6px; + border-left: 1px solid #315b6a; + color: var(--shd-info); + font-size: var(--shd-type-micro); + font-weight: 900; + letter-spacing: 0.08em; +} +.database-weapon-mod-slots > p { + margin: 0; +} .database-item-lines { display: grid; gap: 1px; @@ -13357,6 +13763,123 @@ a.secondary:active { font-weight: 900; letter-spacing: 0.7px; } +.database-header-badge.exotic { + border-color: #74552a; + background: #1a160d; + color: var(--quality-high-end); +} +.database-header-badge.highend { + border-color: color-mix(in srgb, var(--quality-high-end) 58%, #52431a); + background: #1c190c; + color: var(--quality-high-end); +} +.database-header-badge.protocol-badge-standard { + border-color: color-mix(in srgb, var(--quality-standard) 58%, #354142); + background: #111718; + color: var(--quality-standard); +} +.database-header-badge.protocol-badge-specialized { + border-color: color-mix(in srgb, var(--quality-specialized) 58%, #274338); + background: #0e1713; + color: var(--quality-specialized); +} +.database-header-badge.protocol-badge-superior { + border-color: color-mix(in srgb, var(--quality-superior) 58%, #2c3f5a); + background: #10161d; + color: var(--quality-superior); +} +.database-header-badge.protocol-badge-highend { + border-color: color-mix(in srgb, var(--quality-high-end) 58%, #52431a); + background: #1c190c; + color: var(--quality-high-end); +} +.database-protocol-preview { + display: grid; + gap: 10px; + margin: 9px 12px 0; +} +.database-protocol-primary { + min-height: 66px; + display: grid; + grid-template-columns: minmax(130px, 0.7fr) minmax(0, 1fr); + align-items: center; + gap: 9px; + padding: 7px; + border: 1px solid var(--shd-border); + border-left: 3px solid var(--shd-orange); + background: linear-gradient(90deg, #21190e, #101314); +} +.database-protocol-primary span { + padding-left: 5px; + color: #c89138; + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.9px; +} +.database-protocol-primary b { + overflow-wrap: anywhere; + color: var(--shd-orange); + font-size: 20px; + letter-spacing: 0.7px; +} +.database-protocol-level { + min-width: 0; + display: flex; + align-items: center; + gap: 7px; + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + font-weight: 900; + letter-spacing: 0.7px; +} +.database-protocol-level > span { + flex: none; + color: var(--shd-text); +} +.database-protocol-level input { + width: 104px; + accent-color: var(--shd-orange); +} +.database-protocol-talent { + display: grid; +} +.database-protocol-controls { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.database-protocol-diamonds { + display: flex; + gap: 5px; +} +.database-protocol-diamonds button { + min-width: 24px; + min-height: 24px; + padding: 0; + border: 0; + background: transparent; + color: #596164; + font-size: 16px; + cursor: pointer; +} +.database-protocol-diamonds button.active, +.database-protocol-diamonds button:hover, +.database-protocol-diamonds button:focus-visible { + color: var(--quality-high-end); +} +.database-protocol-diamonds button:focus-visible { + outline: 2px solid var(--shd-orange); + outline-offset: 2px; +} +.database-protocol-talent p { + margin: 0; + padding: 9px; + border: 1px solid var(--shd-border); + background: #101314; + color: #a2a8aa; + font: var(--shd-type-supporting)/1.55 var(--shd-font-editorial); +} .database-fact-value { min-width: 0; display: flex; @@ -13691,6 +14214,9 @@ a.secondary:active { .database-item-grid { grid-template-columns: 1fr; } + .database-weapon-mod-slots { + grid-template-columns: 1fr; + } .database-item-card > header { grid-template-columns: 52px minmax(0, 1fr); } diff --git a/src/lib/articles/document.test.ts b/src/lib/articles/document.test.ts index a9d407b..7172cae 100644 --- a/src/lib/articles/document.test.ts +++ b/src/lib/articles/document.test.ts @@ -91,6 +91,172 @@ describe('article documents', () => { ).toBeNull(); }); + it('accepts and canonicalizes metadata emitted by the Tiptap editor', () => { + const parsed = parseArticleDocument({ + version: 1, + document: { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Read more', + marks: [ + { + type: 'link', + attrs: { + href: 'https://shd.build/news', + target: '_blank', + rel: 'noopener noreferrer nofollow', + class: null, + title: 'News archive', + }, + }, + ], + }, + ], + }, + { + type: 'orderedList', + attrs: { start: 1, type: null }, + content: [ + { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'First item' }], + }, + ], + }, + ], + }, + ], + }, + }); + + expect(parsed?.document.document.content).toEqual([ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Read more', + marks: [ + { + type: 'link', + attrs: { + href: 'https://shd.build/news', + title: 'News archive', + }, + }, + ], + }, + ], + }, + { + type: 'orderedList', + content: [ + { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'First item' }], + }, + ], + }, + ], + }, + ]); + expect(parsed?.plainText).toContain('News archive'); + }); + + it('preserves supported ordered-list numbering', () => { + const parsed = parseArticleDocument({ + version: 1, + document: { + type: 'doc', + content: [ + { + type: 'orderedList', + attrs: { start: 2, type: 'A' }, + content: [ + { + type: 'listItem', + content: [{ type: 'paragraph' }], + }, + ], + }, + ], + }, + }); + + expect(parsed?.document.document.content?.[0]).toMatchObject({ + type: 'orderedList', + attrs: { start: 2, type: 'A' }, + }); + expect(parseArticleDocument(parsed?.document)?.document).toEqual( + parsed?.document, + ); + }); + + it('rejects unknown link metadata and non-default ordered lists', () => { + const documentWith = (content: unknown[]) => ({ + version: 1, + document: { type: 'doc', content }, + }); + + expect( + parseArticleDocument( + documentWith([ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'unsafe metadata', + marks: [ + { + type: 'link', + attrs: { + href: 'https://shd.build', + onclick: 'alert(1)', + }, + }, + ], + }, + ], + }, + ]), + ), + ).toBeNull(); + expect( + parseArticleDocument( + documentWith([ + { + type: 'orderedList', + attrs: { start: 0, type: 'decimal' }, + content: [], + }, + ]), + ), + ).toBeNull(); + expect( + parseArticleDocument( + documentWith([ + { + type: 'orderedList', + attrs: { start: 1, type: 1 }, + content: [], + }, + ]), + ), + ).toBeNull(); + }); + it('rejects duplicate embed keys and unknown envelope fields', () => { const embed = { type: 'buildEmbed', diff --git a/src/lib/articles/document.ts b/src/lib/articles/document.ts index 0ade92c..7de7b86 100644 --- a/src/lib/articles/document.ts +++ b/src/lib/articles/document.ts @@ -10,7 +10,7 @@ const EMBED_KEY_PATTERN = /^[a-z0-9][a-z0-9-]{7,63}$/; export type ArticleMark = { type: 'bold' | 'italic' | 'code' | 'link'; - attrs?: { href: string }; + attrs?: { href: string; title?: string }; }; export type ArticleNode = { @@ -53,7 +53,17 @@ const safeLink = (value: unknown) => { } }; -function parseMarks(value: unknown): ArticleMark[] | null { +const nullableBoundedString = (value: unknown, maximum: number) => + value === undefined || + value === null || + (typeof value === 'string' && + value.length <= maximum && + !/\p{Cc}/u.test(value)); + +function parseMarks( + value: unknown, + onLinkTitle: (title: string) => void, +): ArticleMark[] | null { if (value === undefined) return []; if (!Array.isArray(value) || value.length > 4) return null; const seen = new Set(); @@ -77,10 +87,31 @@ function parseMarks(value: unknown): ArticleMark[] | null { !isRecord(candidate.attrs) ) return null; - if (!exactKeys(candidate.attrs, ['href'])) return null; + if ( + !exactKeys(candidate.attrs, [ + 'href', + 'target', + 'rel', + 'class', + 'title', + ]) || + !nullableBoundedString(candidate.attrs.target, 100) || + !nullableBoundedString(candidate.attrs.rel, 500) || + !nullableBoundedString(candidate.attrs.class, 500) || + !nullableBoundedString(candidate.attrs.title, 500) + ) + return null; const href = safeLink(candidate.attrs.href); if (!href) return null; - marks.push({ type: 'link', attrs: { href } }); + const title = + typeof candidate.attrs.title === 'string' + ? candidate.attrs.title.normalize('NFKC').trim() + : ''; + if (title) onLinkTitle(title); + marks.push({ + type: 'link', + attrs: { href, ...(title ? { title } : {}) }, + }); } return marks; } @@ -134,7 +165,9 @@ export function parseArticleDocument(value: unknown): { 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); + const marks = parseMarks(candidate.marks, (title) => + textParts.push(title), + ); if (!marks) return null; textParts.push(text); return marks.length ? { type, text, marks } : { type, text }; @@ -211,6 +244,31 @@ export function parseArticleDocument(value: unknown): { ) return null; attrs = { level: Number(candidate.attrs.level) }; + } else if (type === 'orderedList' && candidate.attrs !== undefined) { + if ( + !isRecord(candidate.attrs) || + !exactKeys(candidate.attrs, ['start', 'type']) + ) + return null; + const start = candidate.attrs.start ?? 1; + const listType = candidate.attrs.type; + if ( + !Number.isInteger(start) || + Number(start) < 1 || + Number(start) > 1_000_000 || + (listType !== null && + listType !== undefined && + (typeof listType !== 'string' || + !['1', 'a', 'A', 'i', 'I'].includes(listType))) + ) + return null; + const canonicalType = + typeof listType === 'string' && listType !== '1' ? listType : undefined; + attrs = { + ...(Number(start) === 1 ? {} : { start: Number(start) }), + ...(canonicalType ? { type: canonicalType } : {}), + }; + if (!Object.keys(attrs).length) attrs = undefined; } else if (candidate.attrs !== undefined) { if (!isRecord(candidate.attrs) || Object.keys(candidate.attrs).length) return null; diff --git a/src/lib/builder/loadout-summary.test.ts b/src/lib/builder/loadout-summary.test.ts index 15f4821..ea29f12 100644 --- a/src/lib/builder/loadout-summary.test.ts +++ b/src/lib/builder/loadout-summary.test.ts @@ -39,6 +39,7 @@ describe('summarizeLoadout', () => { it('returns an empty deterministic summary for a partial draft', () => { expect(summarizeLoadout(createDraft(), catalog())).toEqual({ stats: [], + abilityStats: [], activeBonuses: [], equippedTalents: [], }); @@ -214,6 +215,108 @@ describe('summarizeLoadout', () => { ]); }); + it('keeps Skill Chip primaries ability-scoped while adding secondaries to Agent stats', () => { + const draft = createDraft(); + draft.chips[0] = { + setId: 'chip-set', + quality: 'Superior', + primary: { statId: 'skill-duration', value: 12 }, + secondary: [ + { statId: 'agent-stat', value: 4 }, + { statId: 'other-secondary', value: 6 }, + ], + }; + + const summary = summarizeLoadout( + draft, + catalog({ + attributes: [ + attribute('skill-duration', 'Skill Duration', { unit: '%' }), + attribute('agent-stat', 'Agent Stat', { unit: '%' }), + attribute('other-secondary', 'Other Secondary', { unit: '%' }), + ], + chipSets: [ + { + id: 'chip-set', + name: 'Signal', + specialization_ability_id: 'ability-1', + twoPiece: null, + talent: null, + }, + ], + }), + ); + + expect(stat(summary, 'skill-duration')).toBeUndefined(); + expect(stat(summary, 'agent-stat')?.value).toBe(4); + expect(stat(summary, 'other-secondary')?.value).toBe(6); + expect(summary.abilityStats).toEqual([ + { + abilityId: 'ability-1', + stats: [ + { + id: 'skill-duration', + value: 12, + sourceCount: 1, + sources: [ + { + source: 'Skill Chip 1 primary', + chipIndex: 0, + chipSetId: 'chip-set', + value: 12, + }, + ], + }, + ], + }, + ]); + }); + + it.each([ + [0, 10], + [1, 20], + [2, 30], + [3, 40], + ])( + 'scales a three-chip 2-piece bonus with %i High-end chips', + (highEndCount, expectedValue) => { + const draft = createDraft(); + for (let index = 0; index < 3; index += 1) { + draft.chips[index].setId = 'chip-set'; + draft.chips[index].quality = + index < highEndCount ? 'High-end' : 'Standard'; + } + + const summary = summarizeLoadout( + draft, + catalog({ + attributes: [ + attribute('skill-damage', 'Skill Damage', { unit: '%' }), + ], + chipSets: [ + { + id: 'chip-set', + name: 'Signal', + specialization_ability_id: 'ability', + twoPiece: { + stat_attribute_id: 'skill-damage', + min_value: 10, + max_value: 40, + }, + talent: null, + }, + ], + }), + ); + + expect(stat(summary, 'skill-damage')?.value).toBe(expectedValue); + expect(summary.activeBonuses[0]).toMatchObject({ + count: 3, + value: expectedValue, + }); + }, + ); + it('scales Protocol primary stats and Talent descriptions independently', () => { const draft = createDraft(); draft.protocol = { itemId: 'protocol', level: 50, talentUpgrades: 5 }; diff --git a/src/lib/builder/loadout-summary.ts b/src/lib/builder/loadout-summary.ts index 1bed2ba..cc62fa8 100644 --- a/src/lib/builder/loadout-summary.ts +++ b/src/lib/builder/loadout-summary.ts @@ -7,6 +7,10 @@ export type LoadoutCatalog = { brands: any[]; weapons: any[]; talents: any[]; + skillChipProfile?: { + primaryOptions: any[]; + secondaryOptions: any[]; + }; chipSets: any[]; protocols: any[]; specializations: any[]; @@ -14,6 +18,20 @@ export type LoadoutCatalog = { export type LoadoutSummary = { stats: Array<{ id: string; value: number; sourceCount: number }>; + abilityStats: Array<{ + abilityId: string; + stats: Array<{ + id: string; + value: number; + sourceCount: number; + sources: Array<{ + source: string; + chipIndex: number; + chipSetId: string; + value: number; + }>; + }>; + }>; activeBonuses: any[]; equippedTalents: any[]; }; @@ -139,6 +157,55 @@ export function summarizeLoadout( const brandCounts = new Map(); const activeBonuses: any[] = []; const equippedTalents: any[] = []; + const abilityValues = new Map>(); + const abilitySources = new Map< + string, + Map< + string, + Array<{ + source: string; + chipIndex: number; + chipSetId: string; + value: number; + }> + > + >(); + const addAbilityStat = ( + abilityId: string, + statId: string, + value: unknown, + chipIndex: number, + chipSetId: string, + ) => { + if ( + !abilityId || + !statId || + !catalogAttribute(catalog, statId) || + !Number.isFinite(Number(value)) + ) + return; + if (!abilityValues.has(abilityId)) abilityValues.set(abilityId, new Map()); + const valuesForAbility = abilityValues.get(abilityId)!; + valuesForAbility.set( + statId, + applyAttributeValue( + catalog, + valuesForAbility.get(statId) ?? 0, + statId, + Number(value), + ), + ); + if (!abilitySources.has(abilityId)) + abilitySources.set(abilityId, new Map()); + const sourcesForAbility = abilitySources.get(abilityId)!; + if (!sourcesForAbility.has(statId)) sourcesForAbility.set(statId, []); + sourcesForAbility.get(statId)!.push({ + source: `Skill Chip ${chipIndex + 1} primary`, + chipIndex, + chipSetId, + value: Number(value), + }); + }; for (const slot of gearSlots) { const selection = draft.gear[slot]; @@ -259,17 +326,36 @@ export function summarizeLoadout( string, { count: number; highEnd: number; item: any } >(); + const validChipRoll = ( + kind: 'primaryOptions' | 'secondaryOptions', + quality: string, + roll: { statId: string; value: number }, + ) => { + if (!catalog.skillChipProfile) return true; + const range = catalog.skillChipProfile[kind].find( + (option) => option.statId === roll.statId, + )?.ranges?.[quality]; + return ( + range && + roll.value >= Number(range.min) && + roll.value <= Number(range.max) + ); + }; for (let index = 0; index < 9; index += 1) { const selection = draft.chips[index]; const item = catalog.chipSets.find((entry) => entry.id === selection.setId); if (!item) continue; - add( - selection.primary.statId, - selection.primary.value, - `Skill Chip ${index + 1} primary`, - ); + if (validChipRoll('primaryOptions', selection.quality, selection.primary)) + addAbilityStat( + String(item.specialization_ability_id ?? ''), + selection.primary.statId, + selection.primary.value, + index, + String(item.id), + ); for (const roll of selection.secondary) - add(roll.statId, roll.value, `Skill Chip ${index + 1} secondary`); + if (validChipRoll('secondaryOptions', selection.quality, roll)) + add(roll.statId, roll.value, `Skill Chip ${index + 1} secondary`); const group = chipGroups.get(item.id) ?? { count: 0, highEnd: 0, item }; group.count += 1; if (selection.quality === 'High-end') group.highEnd += 1; @@ -390,6 +476,28 @@ export function summarizeLoadout( catalogAttribute(catalog, right.id)?.name ?? '', ), ), + abilityStats: [...abilityValues].map(([abilityId, abilityStatValues]) => ({ + abilityId, + stats: [...abilityStatValues] + .map(([id, value]) => { + const statSources = abilitySources.get(abilityId)?.get(id) ?? []; + return { + id, + value, + sourceCount: statSources.length, + sources: statSources, + }; + }) + .sort( + (left, right) => + (catalogAttribute(catalog, left.id)?.category ?? '').localeCompare( + catalogAttribute(catalog, right.id)?.category ?? '', + ) || + (catalogAttribute(catalog, left.id)?.name ?? '').localeCompare( + catalogAttribute(catalog, right.id)?.name ?? '', + ), + ), + })), activeBonuses, equippedTalents, }; diff --git a/src/lib/builder/loadout.test.ts b/src/lib/builder/loadout.test.ts index dd659a8..39d7bb2 100644 --- a/src/lib/builder/loadout.test.ts +++ b/src/lib/builder/loadout.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { createDraft, parseLoadoutDraft } from './loadout.js'; +import { + createDraft, + parseLoadoutDraft, + skillChipSecondaryCapacity, + type SkillChipQuality, +} from './loadout.js'; const copyDraft = () => structuredClone(createDraft()); @@ -9,6 +14,13 @@ describe('parseLoadoutDraft', () => { const parsed = parseLoadoutDraft(draft); expect(parsed).toEqual(draft); + expect(parsed?.version).toBe(5); + expect(parsed?.chips[0]).toEqual({ + setId: '', + quality: 'Standard', + primary: { statId: '', value: 0 }, + secondary: [], + }); expect(parsed?.calculationMode).toBe('maximum'); expect(parsed).not.toBe(draft); parsed!.gear.Holster.level = 1; @@ -23,13 +35,61 @@ describe('parseLoadoutDraft', () => { expect(parseLoadoutDraft(retired)).toBeNull(); }); - it('rejects a version 4 draft that does not contain exactly nine chips', () => { + it('rejects the retired version 4 schema', () => { + const retired = copyDraft() as unknown as Record; + retired.version = 4; + + expect(parseLoadoutDraft(retired)).toBeNull(); + }); + + it('rejects a version 5 draft that does not contain exactly nine chips', () => { const draft = copyDraft(); draft.chips.pop(); expect(parseLoadoutDraft(draft)).toBeNull(); }); + it.each([ + ['Standard', 0], + ['Specialized', 1], + ['Superior', 2], + ['High-end', 2], + ] as const)( + 'accepts %s chips with exactly %i secondary rolls', + (quality, capacity) => { + const draft = copyDraft(); + draft.chips[0].quality = quality; + draft.chips[0].primary = { statId: 'primary', value: 10 }; + draft.chips[0].secondary = Array.from( + { length: capacity }, + (_, index) => ({ statId: `secondary-${index}`, value: index + 1 }), + ); + + expect(skillChipSecondaryCapacity[quality]).toBe(capacity); + expect(parseLoadoutDraft(draft)).toEqual(draft); + }, + ); + + it.each([ + ['Standard', 1], + ['Specialized', 0], + ['Specialized', 2], + ['Superior', 1], + ['High-end', 3], + ] as Array<[SkillChipQuality, number]>)( + 'rejects %s chips with %i secondary rolls', + (quality, capacity) => { + const draft = copyDraft(); + draft.chips[0].quality = quality; + draft.chips[0].secondary = Array.from( + { length: capacity }, + (_, index) => ({ statId: `secondary-${index}`, value: index + 1 }), + ); + + expect(parseLoadoutDraft(draft)).toBeNull(); + }, + ); + it('rejects a draft missing the unified combat profile', () => { const draft = copyDraft() as unknown as Record; delete draft.combatProfile; diff --git a/src/lib/builder/loadout.ts b/src/lib/builder/loadout.ts index 5fa7ca0..baa9348 100644 --- a/src/lib/builder/loadout.ts +++ b/src/lib/builder/loadout.ts @@ -1,4 +1,17 @@ export type Roll = { statId: string; value: number }; +export const skillChipQualities = [ + 'Standard', + 'Specialized', + 'Superior', + 'High-end', +] as const; +export type SkillChipQuality = (typeof skillChipQualities)[number]; +export const skillChipSecondaryCapacity: Record = { + Standard: 0, + Specialized: 1, + Superior: 2, + 'High-end': 2, +}; export type GearSelection = { itemId: string; level: number; @@ -26,9 +39,9 @@ export type WeaponSelection = { }; export type ChipSelection = { setId: string; - quality: 'Superior' | 'High-end'; + quality: SkillChipQuality; primary: Roll; - secondary: [Roll, Roll]; + secondary: Roll[]; }; export type ProtocolSelection = { itemId: string; @@ -68,7 +81,7 @@ export type SpecializationSelection = { nodeRanks: Record; }; export type LoadoutDraft = { - version: 4; + version: 5; buildName: string; calculationMode: CalculationMode; gear: Record; @@ -127,9 +140,9 @@ export const blankWeapon = (): WeaponSelection => ({ }); export const blankChip = (): ChipSelection => ({ setId: '', - quality: 'Superior', + quality: 'Standard', primary: blankRoll(), - secondary: [blankRoll(), blankRoll()], + secondary: [], }); export const defaultCombatProfile = (): CombatProfile => ({ encounterSeconds: 60, @@ -148,7 +161,7 @@ export const defaultCombatProfile = (): CombatProfile => ({ extraHealthActive: false, }); export const createDraft = (): LoadoutDraft => ({ - version: 4, + version: 5, buildName: 'Untitled loadout', calculationMode: 'maximum', gear: Object.fromEntries(gearSlots.map((slot) => [slot, blankGear()])), @@ -242,9 +255,12 @@ const isChipSelection = (value: unknown): value is ChipSelection => isRecord(value) && hasExactKeys(value, ['setId', 'quality', 'primary', 'secondary']) && typeof value.setId === 'string' && - (value.quality === 'Superior' || value.quality === 'High-end') && + skillChipQualities.some((quality) => value.quality === quality) && isRoll(value.primary) && - isRollPair(value.secondary); + Array.isArray(value.secondary) && + value.secondary.length === + skillChipSecondaryCapacity[value.quality as SkillChipQuality] && + value.secondary.every(isRoll); const isProtocolSelection = (value: unknown): value is ProtocolSelection => isRecord(value) && hasExactKeys(value, ['itemId', 'level', 'talentUpgrades']) && @@ -353,7 +369,7 @@ export function parseLoadoutDraft(value: unknown): LoadoutDraft | null { 'skillActivations', 'specialization', ]) || - value.version !== 4 || + value.version !== 5 || typeof value.buildName !== 'string' || !['maximum', 'custom'].includes(String(value.calculationMode)) ) diff --git a/src/lib/calculations/mechanic-editor.test.ts b/src/lib/calculations/mechanic-editor.test.ts index de40c40..c6d02d2 100644 --- a/src/lib/calculations/mechanic-editor.test.ts +++ b/src/lib/calculations/mechanic-editor.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest'; import { currentHealthPercentBandPredicate, eventFieldComparisonPredicate, + mechanicTargetingForMode, + mechanicTargetingMode, parseConditionalMechanicDraft, readCurrentHealthPercentBand, readEventFieldComparison, @@ -200,6 +202,110 @@ describe('conditional mechanic authoring payload', () => { }); }); + it('exposes assigned-ability activity for kill and assist event conditions', () => { + expect(registeredEventFields('enemy_killed')).toEqual( + expect.arrayContaining([ + { + path: 'values.assignedAbilityActive', + label: 'Assigned ability active', + type: 'boolean', + }, + ]), + ); + expect(registeredEventFields('assist')).toEqual( + expect.arrayContaining([ + { + path: 'values.assignedAbilityActive', + label: 'Assigned ability active', + type: 'boolean', + }, + ]), + ); + const value = draft(); + value.clauses[0].event = 'enemy_killed'; + value.clauses[0].recipeKey = undefined; + value.clauses[0].reducer = { kind: 'none' }; + value.clauses[0].if = eventFieldComparisonPredicate({ + path: 'values.assignedAbilityActive', + operator: 'eq', + value: true, + }); + + const result = parseConditionalMechanicDraft(value, { + variantKeys: ['I', 'II'], + allowedAttributeIds: new Set(['weapon-damage']), + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.programs.I.clauses[0]).toMatchObject({ + when: 'enemy_killed', + if: value.clauses[0].if, + }); + }); + + it('parses deterministic outcome and clause proc chances with ability mutations', () => { + const value = draft(); + value.clauses[0].event = 'skill_hit'; + value.clauses[0].recipeKey = undefined; + value.clauses[0].procChancePercent = 10; + value.clauses[0].requiresAssignedAbilityActive = true; + value.clauses[0].outcomes[0].probabilityPercent = 29; + value.clauses[0].outcomes[0].statePool = undefined; + value.clauses[0].outcomes[0].perStack = false; + value.pools = [ + { + key: 'extension-stacks', + label: 'Extension stacks', + stateKind: 'stack', + scope: 'agent', + initial: 0, + min: 0, + max: 8, + }, + { + key: 'extension-cooldown', + label: 'Extension cooldown', + stateKind: 'flag', + scope: 'agent', + initial: 0, + min: 0, + max: 1, + expiresAfterSeconds: 15, + }, + ]; + value.clauses[0].mutations = [ + { kind: 'add', pool: 'extension-stacks', value: 1 }, + { kind: 'extend_duration', target: 'assigned_ability', seconds: 0.3 }, + { + kind: 'start_cooldown_on_max', + stackPool: 'extension-stacks', + cooldownPool: 'extension-cooldown', + }, + ]; + const result = parseConditionalMechanicDraft(value, { + variantKeys: ['I', 'II'], + allowedAttributeIds: new Set(['weapon-damage']), + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.programs.I.clauses[0]).toMatchObject({ + procChancePercent: 10, + requiresAssignedAbilityActive: true, + outcomes: [{ probabilityPercent: 29 }], + }); + expect(result.value.programs.I.clauses[0].mutations).toEqual( + expect.arrayContaining([ + { kind: 'extend_duration', target: 'assigned_ability', seconds: 0.3 }, + { + kind: 'start_cooldown_on_max', + stackPool: 'extension-stacks', + cooldownPool: 'extension-cooldown', + }, + ]), + ); + }); + it('rejects an unregistered Extra Health event field deterministically', () => { const value = draft(); value.clauses[0].event = 'extra_health_changed'; @@ -735,4 +841,125 @@ describe('conditional mechanic authoring payload', () => { ); } }); + + it('exposes aiming and round-trips event-target explosion editor state', () => { + expect(registeredEventFields('weapon_hit')).toContainEqual({ + path: 'values.aiming', + label: 'Aiming down sights', + type: 'boolean', + }); + const targeting = mechanicTargetingForMode('event_target_explosion'); + expect(targeting).toEqual({ + kind: 'event_target_area', + center: 'event_target', + validTargets: 'enemies', + radiusMeters: 5, + }); + expect(mechanicTargetingMode(targeting)).toBe('event_target_explosion'); + expect(mechanicTargetingForMode('none', targeting)).toBeUndefined(); + + const valid = draft(); + valid.clauses[0].if = eventFieldComparisonPredicate({ + path: 'values.aiming', + operator: 'eq', + value: true, + }); + valid.clauses[0].outcomes[0] = { + ...valid.clauses[0].outcomes[0], + kind: 'direct_damage', + scope: 'target', + phase: 'after_event', + attributeId: undefined, + targeting, + }; + const parsed = parseConditionalMechanicDraft(valid, { + variantKeys: ['I', 'II'], + allowedAttributeIds: new Set(['weapon-damage']), + }); + expect(parsed.ok).toBe(true); + if (parsed.ok) { + expect(parsed.value.draft.clauses[0].outcomes[0].targeting).toEqual( + targeting, + ); + } + }); + + it('rejects incomplete, mis-scoped, and non-event area damage', () => { + for (const configure of [ + (candidate: ConditionalMechanicDraftV1) => { + candidate.clauses[0].outcomes[0].targeting = { + kind: 'event_target_area', + center: 'event_target', + validTargets: 'enemies', + radiusMeters: 0, + }; + }, + (candidate: ConditionalMechanicDraftV1) => { + candidate.clauses[0].outcomes[0].scope = 'self'; + }, + (candidate: ConditionalMechanicDraftV1) => { + candidate.clauses[0].outcomes[0].phase = 'while_active'; + }, + (candidate: ConditionalMechanicDraftV1) => { + candidate.clauses[0].event = 'enemy_killed'; + }, + ]) { + const invalid = draft(); + invalid.clauses[0].outcomes[0] = { + ...invalid.clauses[0].outcomes[0], + kind: 'direct_damage', + scope: 'target', + phase: 'after_event', + attributeId: undefined, + targeting: { + kind: 'event_target_area', + center: 'event_target', + validTargets: 'enemies', + radiusMeters: 5, + }, + }; + configure(invalid); + expect( + parseConditionalMechanicDraft(invalid, { + variantKeys: ['I', 'II'], + allowedAttributeIds: new Set(['weapon-damage']), + }).ok, + ).toBe(false); + } + }); + + it('requires explicit equality for optional aiming evidence', () => { + for (const predicate of [ + { + kind: 'compare' as const, + left: { source: 'event' as const, path: 'values.aiming' }, + operator: 'ne' as const, + right: true, + }, + { + kind: 'not' as const, + predicate: { + kind: 'compare' as const, + left: { source: 'event' as const, path: 'values.aiming' }, + operator: 'eq' as const, + right: true, + }, + }, + { + kind: 'compare' as const, + left: { source: 'event' as const, path: 'values.aiming' }, + operator: 'ne' as const, + right: { source: 'combat' as const, path: 'currentHealth' }, + }, + ]) { + const invalid = draft(); + invalid.clauses[0].if = predicate; + expect( + parseConditionalMechanicDraft(invalid, { + variantKeys: ['I', 'II'], + allowedAttributeIds: new Set(['weapon-damage']), + }).ok, + ).toBe(false); + } + }); }); diff --git a/src/lib/calculations/mechanic-editor.ts b/src/lib/calculations/mechanic-editor.ts index f5b49f8..10723d2 100644 --- a/src/lib/calculations/mechanic-editor.ts +++ b/src/lib/calculations/mechanic-editor.ts @@ -1,12 +1,14 @@ import { MECHANIC_EVENT_KINDS, MECHANIC_EVENT_REGISTRY, + mechanicOutcomeTargetingError, validateMechanicProgram, type ClauseMutation, type MechanicClause, type MechanicEventKind, type MechanicOutcome, type MechanicOutcomeScope, + type MechanicOutcomeTargeting, type MechanicProgram, type MechanicResource, type NumericExpression, @@ -198,6 +200,44 @@ export function registeredEventFields(event: MechanicEventKind) { return MECHANIC_EVENT_REGISTRY[event].fields ?? []; } +export type MechanicTargetingMode = + | 'none' + | 'allies_within_radius' + | 'enemies_within_radius' + | 'event_target_explosion'; + +export function mechanicTargetingMode( + targeting: MechanicOutcomeTargeting | undefined, +): MechanicTargetingMode { + if (!targeting) return 'none'; + if (targeting.kind === 'event_target_area') return 'event_target_explosion'; + return targeting.population === 'allies' + ? 'allies_within_radius' + : 'enemies_within_radius'; +} + +export function mechanicTargetingForMode( + mode: MechanicTargetingMode, + current?: MechanicOutcomeTargeting, +): MechanicOutcomeTargeting | undefined { + if (mode === 'none') return undefined; + const radiusMeters = + current?.radiusMeters ?? (mode === 'event_target_explosion' ? 5 : 20); + if (mode === 'event_target_explosion') { + return { + kind: 'event_target_area', + center: 'event_target', + validTargets: 'enemies', + radiusMeters, + }; + } + return { + kind: 'within_radius', + population: mode === 'allies_within_radius' ? 'allies' : 'enemies', + radiusMeters, + }; +} + export type StatePoolThresholdComparison = { pool: string; operator: PredicateComparisonOperator; @@ -258,6 +298,7 @@ export const CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES = { 'maxStacks', 'targeting', 'descriptionSlot', + 'probabilityPercent', ], phase: ['on_event', 'after_event', 'on_enter', 'on_exit', 'while_active'], kind: [ @@ -291,6 +332,25 @@ export const CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES = { continuous: 'Valid only for restoration. The value is the total restoration, delivered at a constant rate across durationSeconds. Each activation creates an independent stream; overlapping streams add and overheal is lost when it occurs.', }, + probabilitySemantics: + 'probabilityPercent is an optional inclusive 0 through 100 percentage. In custom simulation, each eligible outcome rolls independently from the evaluation random stream. Maximum mode resolves every nonzero probability as successful.', + targeting: { + withinRadius: { + kind: 'within_radius', + population: ['allies', 'enemies'], + radiusMeters: 'positive finite number', + semantics: + 'Legacy radius metadata. It preserves authored range but does not select or fan out runtime targets.', + }, + eventTargetArea: { + kind: 'event_target_area', + center: 'event_target', + validTargets: 'enemies', + radiusMeters: 'positive finite number', + semantics: + 'Valid only for target-scoped direct damage in on_event or after_event phases on a weapon_hit clause. The triggering event must supply an authoritative event-target area sample. Every valid enemy at or inside the radius receives one equal damage emission, ordered by entity ID.', + }, + }, } as const; const phases = new Set( CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES.phase, @@ -371,6 +431,10 @@ function isReference(value: unknown): value is ValueReference { ); case 'equipment': return typeof value.path === 'string' && Boolean(value.path.trim()); + case 'assigned_ability': + return ( + value.path === 'active' || value.path === 'remainingDurationSeconds' + ); case 'attribute': return ( typeof value.attributeId === 'string' && @@ -527,6 +591,17 @@ function isMutation(value: unknown): value is ClauseMutation { isExpression(value.value)) ); } + if (value.kind === 'extend_duration') { + return value.target === 'assigned_ability' && isExpression(value.seconds); + } + if (value.kind === 'start_cooldown_on_max') { + return ( + typeof value.stackPool === 'string' && + Boolean(value.stackPool.trim()) && + typeof value.cooldownPool === 'string' && + Boolean(value.cooldownPool.trim()) + ); + } return ( ['add', 'set', 'consume', 'derive'].includes(value.kind) && typeof value.pool === 'string' && @@ -724,30 +799,21 @@ function parseOutcome( ) { errors.push(`${path} description slot must be a positive integer.`); } - if (value.targeting !== undefined) { - if ( - !object(value.targeting) || - value.targeting.kind !== 'within_radius' || - !['allies', 'enemies'].includes(String(value.targeting.population)) || - !finite(value.targeting.radiusMeters) || - value.targeting.radiusMeters <= 0 - ) { - errors.push(`${path} has invalid radius targeting.`); - } else if ( - value.targeting.population === 'allies' && - value.scope !== 'allies' && - value.scope !== 'self_and_allies' - ) { - errors.push( - `${path} ally radius targeting requires allies or self-and-allies scope.`, - ); - } else if ( - value.targeting.population === 'enemies' && - value.scope !== 'target' - ) { - errors.push(`${path} enemy radius targeting requires target scope.`); - } + if ( + value.probabilityPercent !== undefined && + (!finite(value.probabilityPercent) || + value.probabilityPercent < 0 || + value.probabilityPercent > 100) + ) { + errors.push(`${path} probability must be from 0 through 100 percent.`); } + const targetingError = mechanicOutcomeTargetingError({ + kind, + scope: value.scope as MechanicOutcomeScope, + phase: value.phase as OutcomePhase, + targeting: value.targeting, + }); + if (targetingError) errors.push(`${path} ${targetingError}`); if (!object(value.variants)) { errors.push(`${path} variants must be an object.`); return null; @@ -828,6 +894,22 @@ function parseClauses( if (candidate.weight !== undefined && !isExpression(candidate.weight)) { errors.push(`${path} has an invalid weight expression.`); } + if ( + candidate.procChancePercent !== undefined && + (!finite(candidate.procChancePercent) || + candidate.procChancePercent < 0 || + candidate.procChancePercent > 100) + ) { + errors.push(`${path} proc chance must be from 0 through 100 percent.`); + } + if ( + candidate.requiresAssignedAbilityActive !== undefined && + typeof candidate.requiresAssignedAbilityActive !== 'boolean' + ) { + errors.push( + `${path} assigned-ability activity requirement must be a boolean.`, + ); + } if ( candidate.cooldownSeconds !== undefined && (!finite(candidate.cooldownSeconds) || candidate.cooldownSeconds <= 0) @@ -902,6 +984,15 @@ function programsForDraft( ? {} : { cooldownSeconds: clause.cooldownSeconds }), ...(clause.weight === undefined ? {} : { weight: clause.weight }), + ...(clause.procChancePercent === undefined + ? {} + : { procChancePercent: clause.procChancePercent }), + ...(clause.requiresAssignedAbilityActive === undefined + ? {} + : { + requiresAssignedAbilityActive: + clause.requiresAssignedAbilityActive, + }), mutations: clause.mutations, outcomes: clause.outcomes.map( ({ diff --git a/src/lib/calculations/mechanic-persistence.test.ts b/src/lib/calculations/mechanic-persistence.test.ts index db702e9..9e0c80a 100644 --- a/src/lib/calculations/mechanic-persistence.test.ts +++ b/src/lib/calculations/mechanic-persistence.test.ts @@ -200,6 +200,143 @@ describe('conditional-effect persistence adapter', () => { }); }); + it('preserves event-target area damage semantics on an outcome', () => { + const result = adaptConditionalEffectProgram( + { + rule: rule(), + statePools: [], + clauses: [ + clause({ + trigger_id: null, + event_kind: 'weapon_hit', + state_pool_id: null, + mutations_json: '[]', + }), + ], + outcomes: [ + outcome({ + application_kind: 'direct_damage', + effect_scope: 'target', + stat_attribute_id: null, + application_phase: 'after_event', + state_pool_id: null, + targeting_json: JSON.stringify({ + kind: 'event_target_area', + center: 'event_target', + validTargets: 'enemies', + radiusMeters: 5, + }), + }), + ], + triggers: [], + }, + { name: 'Critical Explosion', variantKey: 'I' }, + ); + + expect(result.errors).toEqual([]); + expect(result.program?.clauses[0].outcomes?.[0]).toMatchObject({ + kind: 'direct_damage', + scope: 'target', + phase: 'after_event', + targeting: { + kind: 'event_target_area', + center: 'event_target', + validTargets: 'enemies', + radiusMeters: 5, + }, + }); + }); + + it('adapts assigned-ability activity predicates for kill and assist events', () => { + for (const eventKind of ['enemy_killed', 'assist'] as const) { + const result = adaptConditionalEffectProgram( + { + rule: rule(), + statePools: [], + clauses: [ + clause({ + trigger_id: null, + event_kind: eventKind, + parameters_json: '{}', + state_pool_id: null, + mutations_json: '[]', + conditions_json: JSON.stringify({ + kind: 'compare', + left: { + source: 'event', + path: 'values.assignedAbilityActive', + }, + operator: 'eq', + right: true, + }), + }), + ], + outcomes: [outcome({ state_pool_id: null })], + triggers: [], + }, + { variantKey: 'I' }, + ); + + expect(result).toMatchObject({ status: 'READY', errors: [] }); + expect(result.program?.clauses[0]).toMatchObject({ + when: eventKind, + if: { + kind: 'compare', + left: { + source: 'event', + path: 'values.assignedAbilityActive', + }, + operator: 'eq', + right: true, + }, + }); + } + }); + + it('preserves outcome probability metadata', () => { + const result = adaptConditionalEffectProgram( + { + rule: rule(), + statePools: [], + clauses: [ + clause({ + trigger_id: null, + event_kind: 'shot_fired', + recipe_parameters_json: JSON.stringify({ + procChancePercent: 10, + requiresAssignedAbilityActive: true, + }), + parameters_json: '{}', + state_pool_id: null, + mutations_json: '[]', + }), + ], + outcomes: [ + outcome({ + state_pool_id: null, + application_kind: 'restore_ammo', + effect_scope: 'self', + stat_attribute_id: null, + resource_kind: 'ammo', + probability_percent: 29, + }), + ], + triggers: [], + }, + { variantKey: 'I' }, + ); + + expect(result).toMatchObject({ status: 'READY', errors: [] }); + expect(result.program?.clauses[0].outcomes?.[0]).toMatchObject({ + probabilityPercent: 29, + resource: 'ammo', + }); + expect(result.program?.clauses[0]).toMatchObject({ + procChancePercent: 10, + requiresAssignedAbilityActive: true, + }); + }); + it('loads continuous restoration delivery metadata', () => { const result = adaptConditionalEffectProgram( { diff --git a/src/lib/calculations/mechanic-persistence.ts b/src/lib/calculations/mechanic-persistence.ts index 5926075..19d2e9d 100644 --- a/src/lib/calculations/mechanic-persistence.ts +++ b/src/lib/calculations/mechanic-persistence.ts @@ -54,6 +54,7 @@ export type ConditionalEffectClauseRow = { event_kind?: DatabaseValue; recipe_key?: DatabaseValue; parameters_json: DatabaseValue; + recipe_parameters_json?: DatabaseValue; conditions_json: DatabaseValue; reducer_json: DatabaseValue; weight_expression_json?: DatabaseValue; @@ -85,6 +86,7 @@ export type ConditionalEffectOutcomeRow = { delivery_mode?: DatabaseValue; delivery_duration_seconds?: DatabaseValue; targeting_json?: DatabaseValue; + probability_percent?: DatabaseValue; value_scaling: DatabaseValue; unit: DatabaseValue; damage_basis: DatabaseValue; @@ -205,6 +207,9 @@ function isValueReference(value: unknown) { if (value.source === 'event' || value.source === 'equipment') { return typeof value.path === 'string' && Boolean(value.path.trim()); } + if (value.source === 'assigned_ability') { + return value.path === 'active' || value.path === 'remainingDurationSeconds'; + } if (value.source === 'combat') { return ( typeof value.path === 'string' && @@ -313,6 +318,19 @@ function isMutation(value: unknown): value is ClauseMutation { isNumericExpression(value.value)) ); } + if (value.kind === 'extend_duration') { + return ( + value.target === 'assigned_ability' && isNumericExpression(value.seconds) + ); + } + if (value.kind === 'start_cooldown_on_max') { + return ( + typeof value.stackPool === 'string' && + Boolean(value.stackPool.trim()) && + typeof value.cooldownPool === 'string' && + Boolean(value.cooldownPool.trim()) + ); + } return ( ['add', 'set', 'consume', 'derive'].includes(value.kind) && typeof value.pool === 'string' && @@ -731,6 +749,17 @@ function parseOutcome( if (delivery === 'instant' && durationSeconds !== null) { errors.push(`${path} instant delivery cannot set a duration.`); } + const probabilityPercent = number(row.probability_percent); + if ( + row.probability_percent !== undefined && + row.probability_percent !== null && + row.probability_percent !== '' && + (probabilityPercent === null || + probabilityPercent < 0 || + probabilityPercent > 100) + ) { + errors.push(`${path} probability must be from 0 through 100 percent.`); + } let targeting: MechanicOutcome['targeting'] | undefined; if ( row.targeting_json !== null && @@ -766,6 +795,7 @@ function parseOutcome( ? { delivery, durationSeconds } : {}), ...(targeting ? { targeting } : {}), + ...(probabilityPercent === null ? {} : { probabilityPercent }), ...(kind === 'attribute_modifier' && nullableText(row.stat_attribute_id) ? { attributeId: text(row.stat_attribute_id) } : {}), @@ -903,7 +933,7 @@ export function adaptConditionalEffectProgram( continue; } const parametersJson = parseJson( - row.parameters_json, + row.recipe_parameters_json ?? row.parameters_json, `${path} parameters`, errors, ); @@ -976,6 +1006,25 @@ export function adaptConditionalEffectProgram( } } const recipeKey = nullableText(row.recipe_key); + const procChancePercent = parametersJson.value.procChancePercent; + if ( + procChancePercent !== undefined && + (!finite(procChancePercent) || + procChancePercent < 0 || + procChancePercent > 100) + ) { + errors.push(`${path} proc chance must be from 0 through 100 percent.`); + } + const requiresAssignedAbilityActive = + parametersJson.value.requiresAssignedAbilityActive; + if ( + requiresAssignedAbilityActive !== undefined && + typeof requiresAssignedAbilityActive !== 'boolean' + ) { + errors.push( + `${path} assigned-ability activity requirement must be a boolean.`, + ); + } clauses.push({ id, label: text(row.label) || text(row.clause_key) || undefined, @@ -989,6 +1038,10 @@ export function adaptConditionalEffectProgram( ? {} : { cooldownSeconds: number(row.cooldown_seconds) as number }), ...(weight === undefined ? {} : { weight }), + ...(typeof procChancePercent === 'number' ? { procChancePercent } : {}), + ...(typeof requiresAssignedAbilityActive === 'boolean' + ? { requiresAssignedAbilityActive } + : {}), mutations: mutationsJson.value, ...(clauseOutcomes.length ? { outcomes: clauseOutcomes } : {}), }); diff --git a/src/lib/calculations/mechanic.test.ts b/src/lib/calculations/mechanic.test.ts index 788fbd7..c1969c4 100644 --- a/src/lib/calculations/mechanic.test.ts +++ b/src/lib/calculations/mechanic.test.ts @@ -19,6 +19,276 @@ const custom = ( inputs, }); +describe('deterministic mechanic probabilities and assigned abilities', () => { + it('rolls outcome probability independently with a reproducible seed and preserves a refreshed window', () => { + const program: MechanicProgram = { + id: 'ammo-refill', + name: 'Ammo Refill', + pools: [ + { + key: 'tactical-link-window', + label: 'Tactical Link window', + scope: 'agent', + initial: 0, + min: 0, + max: 1, + expiresAfterSeconds: 6, + }, + ], + clauses: [ + { + id: 'start-window', + when: 'signature_ability_deployed', + reducer: { kind: 'none' }, + mutations: [{ kind: 'set', pool: 'tactical-link-window', value: 1 }], + }, + { + id: 'refresh-window', + when: 'enemy_killed', + reducer: { kind: 'none' }, + mutations: [{ kind: 'set', pool: 'tactical-link-window', value: 1 }], + }, + { + id: 'recover-ammo', + when: 'shot_fired', + if: { + kind: 'compare', + left: { source: 'pool', pool: 'tactical-link-window' }, + operator: 'eq', + right: 1, + }, + reducer: { kind: 'none' }, + mutations: [], + outcomes: [ + { + id: 'recover-one-ammo', + label: 'Recover one ammo', + phase: 'after_event', + kind: 'restoration', + scope: 'self', + basis: 'flat', + resource: 'ammo', + value: 1, + probabilityPercent: 29, + }, + ], + }, + ], + }; + const events = [ + { + kind: 'signature_ability_deployed' as const, + at: 0, + values: { assignedAbilityDurationSeconds: 6 }, + }, + { kind: 'shot_fired' as const, at: 1 }, + { kind: 'shot_fired' as const, at: 2 }, + { kind: 'enemy_killed' as const, at: 5 }, + { kind: 'shot_fired' as const, at: 6 }, + { kind: 'shot_fired' as const, at: 11 }, + ]; + const first = evaluateMechanicProgram( + program, + events, + { mode: 'custom', encounterSeconds: 12, randomSeed: 1 }, + { maxHealth: 100, currentHealth: 100, maxAmmo: 20, currentAmmo: 5 }, + ); + const second = evaluateMechanicProgram( + program, + events, + { mode: 'custom', encounterSeconds: 12, randomSeed: 1 }, + { maxHealth: 100, currentHealth: 100, maxAmmo: 20, currentAmmo: 5 }, + ); + + expect(first.status).toBe('OK'); + expect(first.randomSeed).toBe(1); + expect(first.outcomes.map((outcome) => outcome.at)).toEqual([2]); + expect(first.outcomes[0]).toMatchObject({ + probabilityPercent: 29, + probabilityRoll: expect.any(Number), + appliedValue: 1, + }); + expect(first.finalCombat?.currentAmmo).toBe(6); + expect(second.outcomes).toEqual(first.outcomes); + }); + + it('gates a proc before mutations, extends a live assigned ability, and starts cooldown on the eighth stack', () => { + const program: MechanicProgram = { + id: 'tactical-loop', + name: 'Tactical Loop', + pools: [ + { + key: 'extension-stacks', + label: 'Extension stacks', + scope: 'agent', + initial: 7, + min: 0, + max: 8, + }, + { + key: 'extension-cooldown', + label: 'Extension cooldown', + scope: 'agent', + initial: 0, + min: 0, + max: 1, + expiresAfterSeconds: 15, + }, + ], + clauses: [ + { + id: 'extend-tactical-link', + when: 'skill_hit', + if: { + kind: 'compare', + left: { source: 'pool', pool: 'extension-cooldown' }, + operator: 'eq', + right: 0, + }, + requiresAssignedAbilityActive: true, + procChancePercent: 10, + reducer: { kind: 'none' }, + mutations: [ + { kind: 'add', pool: 'extension-stacks', value: 1 }, + { + kind: 'extend_duration', + target: 'assigned_ability', + seconds: 0.3, + }, + { + kind: 'start_cooldown_on_max', + stackPool: 'extension-stacks', + cooldownPool: 'extension-cooldown', + }, + ], + }, + ], + }; + const success = evaluateMechanicProgram( + program, + [ + { + kind: 'signature_ability_deployed', + at: 0, + values: { assignedAbilityDurationSeconds: 6 }, + }, + { kind: 'skill_hit', at: 5.9 }, + { kind: 'skill_hit', at: 6.1 }, + ], + { mode: 'custom', encounterSeconds: 7, randomSeed: 7 }, + ); + const failed = evaluateMechanicProgram( + program, + [ + { + kind: 'signature_ability_deployed', + at: 0, + values: { assignedAbilityDurationSeconds: 6 }, + }, + { kind: 'skill_hit', at: 5.9 }, + ], + { mode: 'custom', encounterSeconds: 7, randomSeed: 1 }, + ); + + expect(success.status).toBe('OK'); + expect(success.emissions).toHaveLength(1); + const extension = success.emissions[0].mutations.find( + (mutation) => mutation.kind === 'extend_duration', + ); + expect(extension).toMatchObject({ kind: 'extend_duration' }); + expect(extension?.before).toBeCloseTo(0.1); + expect(extension?.after).toBeCloseTo(0.4); + expect(success.emissions[0].mutations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: 'start_cooldown_on_max', + pool: 'extension-cooldown', + after: 1, + }), + ]), + ); + expect(success.finalState).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + pool: 'extension-stacks', + value: 8, + }), + expect.objectContaining({ + pool: 'extension-cooldown', + value: 1, + expiresAt: 20.9, + }), + ]), + ); + expect(failed.emissions).toEqual([]); + }); + + it('does not extend an assigned ability at its exact expiry and rejects invalid chance data', () => { + const boundaryProgram: MechanicProgram = { + id: 'exact-expiry', + name: 'Exact expiry', + clauses: [ + { + id: 'extend', + when: 'skill_hit', + requiresAssignedAbilityActive: true, + procChancePercent: 100, + reducer: { kind: 'none' }, + mutations: [ + { + kind: 'extend_duration', + target: 'assigned_ability', + seconds: 0.3, + }, + ], + }, + ], + }; + expect( + evaluateMechanicProgram( + boundaryProgram, + [ + { + kind: 'signature_ability_deployed', + at: 0, + values: { assignedAbilityDurationSeconds: 1 }, + }, + { kind: 'skill_hit', at: 1 }, + ], + { mode: 'custom', encounterSeconds: 2, randomSeed: 7 }, + ).emissions, + ).toEqual([]); + + const invalid = structuredClone(boundaryProgram); + invalid.clauses[0].procChancePercent = 101; + expect(validateMechanicProgram(invalid)).toMatchObject({ + ok: false, + errors: [ + expect.stringContaining('proc chance must be from 0 through 100'), + ], + }); + const invalidOutcome = structuredClone(boundaryProgram); + invalidOutcome.clauses[0].outcomes = [ + { + id: 'bad-chance', + label: 'Bad chance', + phase: 'after_event', + kind: 'marker', + scope: 'self', + basis: 'flat', + value: 1, + probabilityPercent: -1, + }, + ]; + expect(validateMechanicProgram(invalidOutcome)).toMatchObject({ + ok: false, + errors: [ + expect.stringContaining('probability must be from 0 through 100'), + ], + }); + }); +}); + describe('structured mechanic runtime', () => { it('advertises the authoritative weapon-hit target and headshot fields', () => { expect(MECHANIC_EVENT_REGISTRY.weapon_hit).toMatchObject({ @@ -32,6 +302,11 @@ describe('structured mechanic runtime', () => { }, { path: 'values.headshot', label: 'Headshot', type: 'boolean' }, { path: 'values.critical', label: 'Critical hit', type: 'boolean' }, + { + path: 'values.aiming', + label: 'Aiming down sights', + type: 'boolean', + }, { path: 'values.behindCover', label: 'Agent behind cover', @@ -305,6 +580,161 @@ describe('structured mechanic runtime', () => { }); }); + it('matches kills and assists only while the assigned ability is active', () => { + const weaponCriticalHitChance = 'weapon-critical-hit-chance'; + const activeOutcome = { + id: 'tactical-link-critical-chance', + label: 'Weapon Critical Hit Chance', + phase: 'while_active' as const, + kind: 'attribute_modifier' as const, + scope: 'self' as const, + basis: 'flat' as const, + value: 10.2, + attributeId: weaponCriticalHitChance, + statePool: 'tactical-link-kill-bonus', + }; + const activePredicate = { + kind: 'compare' as const, + left: { + source: 'event' as const, + path: 'values.assignedAbilityActive', + }, + operator: 'eq' as const, + right: true, + }; + const program: MechanicProgram = { + id: 'tactical-link-kill-bonus', + name: 'Tactical Link Kill Bonus', + pools: [ + { + key: 'tactical-link-kill-bonus', + label: 'Tactical Link kill bonus', + scope: 'agent', + initial: 0, + min: 0, + max: 1, + expiresAfterSeconds: 10, + }, + ], + clauses: [ + { + id: 'kill-during-tactical-link', + when: 'enemy_killed', + if: activePredicate, + reducer: { kind: 'none' }, + mutations: [ + { kind: 'set', pool: 'tactical-link-kill-bonus', value: 1 }, + ], + outcomes: [activeOutcome], + }, + { + id: 'assist-during-tactical-link', + when: 'assist', + if: activePredicate, + reducer: { kind: 'none' }, + mutations: [ + { kind: 'set', pool: 'tactical-link-kill-bonus', value: 1 }, + ], + outcomes: [{ ...activeOutcome }], + }, + ], + }; + + expect(validateMechanicProgram(program)).toMatchObject({ ok: true }); + const result = evaluateMechanicProgram( + program, + [ + { + kind: 'enemy_killed', + at: 0, + values: { assignedAbilityActive: true }, + }, + { + kind: 'assist', + at: 4, + values: { assignedAbilityActive: false }, + }, + { kind: 'enemy_killed', at: 6 }, + { + kind: 'assist', + at: 10, + values: { assignedAbilityActive: true }, + }, + ], + custom(19), + { + maxHealth: 1_000, + currentHealth: 1_000, + attributes: { [weaponCriticalHitChance]: 0 }, + }, + ); + + expect(result.status).toBe('OK'); + expect(result.outcomes.map((outcome) => outcome.at)).toEqual([0, 10]); + expect(result.finalState).toContainEqual( + expect.objectContaining({ + pool: 'tactical-link-kill-bonus', + value: 1, + expiresAt: 20, + }), + ); + }); + + it('rejects invalid assigned-ability activity predicates and event values', () => { + const program: MechanicProgram = { + id: 'assigned-ability-activity', + name: 'Assigned ability activity', + clauses: [ + { + id: 'kill', + when: 'enemy_killed', + if: { + kind: 'compare', + left: { + source: 'event', + path: 'values.assignedAbilityActive', + }, + operator: 'eq', + right: true, + }, + reducer: { kind: 'none' }, + mutations: [], + }, + ], + }; + const invalidPredicate = structuredClone(program); + invalidPredicate.clauses[0].if = { + kind: 'compare', + left: { source: 'event', path: 'values.assignedAbilityActive' }, + operator: 'eq', + right: 'yes', + }; + expect(validateMechanicProgram(invalidPredicate)).toMatchObject({ + ok: false, + errors: [ + expect.stringContaining('Assigned ability active with a boolean value'), + ], + }); + expect( + evaluateMechanicProgram( + program, + [ + { + kind: 'enemy_killed', + at: 0, + values: { assignedAbilityActive: 'yes' }, + } as unknown as MechanicEvent, + ], + custom(1), + ), + ).toMatchObject({ + status: 'INVALID', + errors: [ + expect.stringContaining('Assigned ability active must be a boolean'), + ], + }); + }); + it('executes imported talent trigger families as first-class events', () => { const eventKinds: MechanicEventKind[] = [ 'ally_downed', @@ -2550,4 +2980,251 @@ describe('mechanic validation and pseudo-script', () => { expect(result.script).toContain('TO ALLIES WITHIN 40m'); expect(result.script).toContain('TO ENEMIES WITHIN 20m'); }); + + it('applies aimed critical-hit area damage once to each valid enemy at or inside the radius', () => { + const program: MechanicProgram = { + id: 'aimed-critical-explosion', + name: 'Aimed Critical Explosion', + clauses: [ + { + id: 'aimed-critical-hit', + when: 'weapon_hit', + if: { + kind: 'all', + predicates: [ + { + kind: 'compare', + left: { source: 'event', path: 'values.critical' }, + operator: 'eq', + right: true, + }, + { + kind: 'compare', + left: { source: 'event', path: 'values.aiming' }, + operator: 'eq', + right: true, + }, + ], + }, + reducer: { kind: 'none' }, + mutations: [], + outcomes: [ + { + id: 'explosion', + label: 'Explosion', + phase: 'after_event', + kind: 'direct_damage', + scope: 'target', + basis: 'flat', + value: { + op: 'multiply', + left: 20, + right: { + ref: { source: 'attribute', attributeId: 'firepower' }, + }, + }, + targeting: { + kind: 'event_target_area', + center: 'event_target', + validTargets: 'enemies', + radiusMeters: 5, + }, + probabilityPercent: 100, + }, + ], + }, + ], + }; + const result = evaluateMechanicProgram( + program, + [ + { + kind: 'weapon_hit', + at: 1, + targetId: 'enemy-b', + values: { critical: true, aiming: true }, + areaTargetSample: { + center: 'event_target', + validTargets: [ + { + entityId: 'enemy-c', + population: 'enemy', + distanceMeters: 5.001, + }, + { entityId: 'enemy-b', population: 'enemy', distanceMeters: 0 }, + { entityId: 'ally-a', population: 'ally', distanceMeters: 2 }, + { entityId: 'enemy-a', population: 'enemy', distanceMeters: 5 }, + ], + }, + }, + ], + custom(5), + { maxHealth: 1000, currentHealth: 1000, attributes: { firepower: 100 } }, + ); + + expect(result.status).toBe('OK'); + expect(result.outcomes.map((outcome) => outcome.targetId)).toEqual([ + 'enemy-a', + 'enemy-b', + ]); + expect(result.outcomes.map((outcome) => outcome.appliedValue)).toEqual([ + 2000, 2000, + ]); + expect( + new Set(result.outcomes.map((outcome) => outcome.probabilityRoll)).size, + ).toBe(1); + expect(result.outcomes[0].probabilityRoll).not.toBeNull(); + expect(result.script).toContain( + 'EXPLOSION CENTERED ON EVENT TARGET; APPLY TO VALID ENEMIES WITHIN 5m', + ); + const maximum = evaluateMechanicProgram( + program, + [ + { + kind: 'weapon_hit', + at: 1, + targetId: 'enemy-b', + values: { critical: true, aiming: true }, + areaTargetSample: { + center: 'event_target', + validTargets: [ + { entityId: 'enemy-b', population: 'enemy', distanceMeters: 0 }, + { entityId: 'enemy-a', population: 'enemy', distanceMeters: 5 }, + ], + }, + }, + ], + { mode: 'maximum', encounterSeconds: 5 }, + { maxHealth: 1000, currentHealth: 1000, attributes: { firepower: 100 } }, + ); + expect(maximum.outcomes.map((outcome) => outcome.targetId)).toEqual([ + 'enemy-a', + 'enemy-b', + ]); + expect( + maximum.outcomes.every((outcome) => outcome.probabilityRoll === 0), + ).toBe(true); + }); + + it('requires authoritative area evidence only when an area outcome activates', () => { + const program: MechanicProgram = { + id: 'area-evidence', + name: 'Area Evidence', + clauses: [ + { + id: 'critical-hit', + when: 'weapon_hit', + if: { + kind: 'compare', + left: { source: 'event', path: 'values.critical' }, + operator: 'eq', + right: true, + }, + reducer: { kind: 'none' }, + mutations: [], + outcomes: [ + { + id: 'explosion', + label: 'Explosion', + phase: 'after_event', + kind: 'direct_damage', + scope: 'target', + basis: 'flat', + value: 1, + targeting: { + kind: 'event_target_area', + center: 'event_target', + validTargets: 'enemies', + radiusMeters: 5, + }, + }, + ], + }, + ], + }; + expect( + evaluateMechanicProgram( + program, + [ + { + kind: 'weapon_hit', + at: 1, + targetId: 'enemy', + values: { critical: false }, + }, + ], + custom(5), + ), + ).toMatchObject({ status: 'OK', outcomes: [] }); + expect( + evaluateMechanicProgram( + program, + [ + { + kind: 'weapon_hit', + at: 1, + targetId: 'enemy', + values: { critical: true }, + }, + ], + custom(5), + ), + ).toMatchObject({ + status: 'INVALID', + errors: [ + expect.stringContaining('authoritative event-target area sample'), + ], + }); + }); + + it('rejects malformed and duplicate area-target samples', () => { + const result = evaluateMechanicProgram( + { id: 'sample-validation', name: 'Sample Validation', clauses: [] }, + [ + { + kind: 'weapon_hit', + at: 1, + targetId: 'enemy', + areaTargetSample: { + center: 'event_target', + validTargets: [ + { entityId: 'enemy', population: 'enemy', distanceMeters: 0 }, + { entityId: 'enemy', population: 'enemy', distanceMeters: 1 }, + ], + }, + }, + ], + custom(5), + ); + expect(result).toMatchObject({ + status: 'INVALID', + errors: [expect.stringContaining('duplicates entity "enemy"')], + }); + + const oversizedTargets = Array.from({ length: 257 }, (_, index) => ({ + entityId: index === 0 ? 'enemy' : `enemy-${index}`, + population: 'enemy' as const, + distanceMeters: index === 0 ? 0 : 1, + })); + expect( + evaluateMechanicProgram( + { id: 'sample-bound', name: 'Sample Bound', clauses: [] }, + [ + { + kind: 'weapon_hit', + at: 1, + targetId: 'enemy', + areaTargetSample: { + center: 'event_target', + validTargets: oversizedTargets, + }, + }, + ], + custom(5), + ), + ).toMatchObject({ + status: 'INVALID', + errors: [expect.stringContaining('cannot contain more than 256')], + }); + }); }); diff --git a/src/lib/calculations/mechanic.ts b/src/lib/calculations/mechanic.ts index 43bca7c..7993bc0 100644 --- a/src/lib/calculations/mechanic.ts +++ b/src/lib/calculations/mechanic.ts @@ -68,7 +68,7 @@ export const MECHANIC_EVENT_REGISTRY: Readonly< key: 'weapon_hit', label: 'Weapon hit', description: - 'A weapon attack connects with a target. The previous target and event values distinguish target switches, headshots, cover state, and distance.', + 'A weapon attack connects with a target. The previous target and event values distinguish target switches, headshots, critical hits, aiming state, cover state, and distance. An optional area-target sample lists valid entities by distance from the hit target.', fields: [ { path: 'targetId', label: 'Target ID', type: 'string' }, { @@ -78,6 +78,7 @@ export const MECHANIC_EVENT_REGISTRY: Readonly< }, { path: 'values.headshot', label: 'Headshot', type: 'boolean' }, { path: 'values.critical', label: 'Critical hit', type: 'boolean' }, + { path: 'values.aiming', label: 'Aiming down sights', type: 'boolean' }, { path: 'values.behindCover', label: 'Agent behind cover', @@ -93,12 +94,30 @@ export const MECHANIC_EVENT_REGISTRY: Readonly< skill_deployed: { key: 'skill_deployed', label: 'Skill deployed', - description: 'The Agent deploys or activates a specialization ability.', + description: + 'The Agent deploys or activates a specialization ability. An assigned-ability deployment may supply its active duration.', + fields: [ + { + path: 'values.assignedAbilityDurationSeconds', + label: 'Assigned ability duration', + type: 'number', + minimum: 0, + }, + ], }, signature_ability_deployed: { key: 'signature_ability_deployed', label: 'Signature Ability deployed', - description: 'The Agent deploys or activates their Signature Ability.', + description: + 'The Agent deploys or activates their Signature Ability. An assigned-ability deployment may supply its active duration.', + fields: [ + { + path: 'values.assignedAbilityDurationSeconds', + label: 'Assigned ability duration', + type: 'number', + minimum: 0, + }, + ], }, skill_hit: { key: 'skill_hit', @@ -107,6 +126,11 @@ export const MECHANIC_EVENT_REGISTRY: Readonly< 'A deployed skill connects with a target. Critical and distance details belong in event values.', fields: [ { path: 'values.critical', label: 'Critical hit', type: 'boolean' }, + { + path: 'values.assignedAbilityActive', + label: 'Assigned ability active', + type: 'boolean', + }, { path: 'values.distanceMeters', label: 'Target distance', @@ -153,7 +177,7 @@ export const MECHANIC_EVENT_REGISTRY: Readonly< key: 'enemy_killed', label: 'Enemy killed', description: - 'An enemy dies. Weapon or skill source may be supplied when a mechanic requires the distinction.', + "An enemy dies. Weapon or skill source and whether the source item's assigned ability is active may be supplied when a mechanic requires the distinction.", fields: [ { path: 'values.headshot', @@ -166,6 +190,11 @@ export const MECHANIC_EVENT_REGISTRY: Readonly< type: 'boolean', }, { path: 'values.source', label: 'Killing source', type: 'string' }, + { + path: 'values.assignedAbilityActive', + label: 'Assigned ability active', + type: 'boolean', + }, { path: 'values.behindCover', label: 'Agent behind cover', @@ -182,8 +211,13 @@ export const MECHANIC_EVENT_REGISTRY: Readonly< key: 'assist', label: 'Assist', description: - 'The Agent receives credit for assisting with an enemy defeat.', + "The Agent receives credit for assisting with an enemy defeat. The event may identify whether the source item's assigned ability is active.", fields: [ + { + path: 'values.assignedAbilityActive', + label: 'Assigned ability active', + type: 'boolean', + }, { path: 'values.behindCover', label: 'Agent behind cover', @@ -315,12 +349,26 @@ export type CalculationMode = 'maximum' | 'custom'; export type OutcomePhase = 'on_event' | 'after_event' | 'on_enter' | 'on_exit' | 'while_active'; +export type MechanicAreaTarget = { + entityId: string; + population: 'ally' | 'enemy'; + distanceMeters: number; +}; + +export type MechanicAreaTargetSample = { + center: 'event_target'; + validTargets: MechanicAreaTarget[]; +}; + +export const MAX_MECHANIC_AREA_TARGETS = 256; + export type MechanicEvent = { kind: MechanicEventKind; at: number; actorId?: string | null; targetId?: string | null; values?: Record; + areaTargetSample?: MechanicAreaTargetSample; }; export type ScenarioInputDefinition = { @@ -336,11 +384,12 @@ export type ScenarioInputDefinition = { }; export type MechanicScenario = - | { mode: 'maximum'; encounterSeconds: number } + | { mode: 'maximum'; encounterSeconds: number; randomSeed?: number } | { mode: 'custom'; encounterSeconds: number; inputs?: Record; + randomSeed?: number; }; export type EntitySelector = @@ -355,6 +404,10 @@ export type ValueReference = | { source: 'combat'; path: string; timing?: 'pre_event' | 'current' } | { source: 'attribute'; attributeId: string } | { source: 'equipment'; path: string } + | { + source: 'assigned_ability'; + path: 'active' | 'remainingDurationSeconds'; + } | { source: 'reducer' } | { source: 'pool'; pool: string; entity?: EntitySelector }; @@ -466,6 +519,16 @@ export type ClauseMutation = to: EntitySelector; value?: NumericExpression | 'all'; clearSource?: boolean; + } + | { + kind: 'extend_duration'; + target: 'assigned_ability'; + seconds: NumericExpression; + } + | { + kind: 'start_cooldown_on_max'; + stackPool: string; + cooldownPool: string; }; export type OutcomeBasis = @@ -487,11 +550,18 @@ export type MechanicOutcomeScope = export type MechanicResource = 'health' | 'armor' | 'ammo' | 'signature_ability_charge'; -export type MechanicOutcomeTargeting = { - kind: 'within_radius'; - population: 'allies' | 'enemies'; - radiusMeters: number; -}; +export type MechanicOutcomeTargeting = + | { + kind: 'within_radius'; + population: 'allies' | 'enemies'; + radiusMeters: number; + } + | { + kind: 'event_target_area'; + center: 'event_target'; + validTargets: 'enemies'; + radiusMeters: number; + }; export type MechanicOutcome = { id: string; @@ -517,6 +587,7 @@ export type MechanicOutcome = { minStacks?: number; maxStacks?: number; targeting?: MechanicOutcomeTargeting; + probabilityPercent?: number; }; export type MechanicClause = { @@ -528,6 +599,8 @@ export type MechanicClause = { reducer: Reducer; cooldownSeconds?: number; weight?: NumericExpression; + procChancePercent?: number; + requiresAssignedAbilityActive?: boolean; mutations: ClauseMutation[]; outcomes?: MechanicOutcome[]; }; @@ -621,6 +694,8 @@ export type MechanicOutcomeEmission = { deliveryEnd: number | null; totalResolvedValue: number; targeting: MechanicOutcomeTargeting | null; + probabilityPercent: number | null; + probabilityRoll: number | null; }; export type MechanicEvaluation = { @@ -639,6 +714,7 @@ export type MechanicEvaluation = { finalCombat: MechanicCombatContext | null; equipment: MechanicEquipmentContext | null; estimatedActivationCount: number; + randomSeed: number | null; script: string; }; @@ -658,6 +734,10 @@ export type MechanicCombatContext = { export type MechanicEquipmentContext = { protocolTalentUpgrades?: number; + assignedAbility?: { + active: boolean; + remainingDurationSeconds: number; + }; values?: Record; }; @@ -665,6 +745,10 @@ export type ValidationResult = { ok: true; value: T } | { ok: false; errors: string[] }; type RuntimePool = PoolSnapshot; +type RuntimeAssignedAbility = { + active: boolean; + expiresAt: number | null; +}; type EvaluationContext = { event: MechanicEvent; scenario: Record; @@ -674,6 +758,10 @@ type EvaluationContext = { combat: MechanicCombatContext | null; preEventCombat: MechanicCombatContext | null; equipment: MechanicEquipmentContext | null; + assignedAbility: RuntimeAssignedAbility; + mode: CalculationMode; + random: DeterministicRandom; + runtimeErrors: string[]; }; type ContinuousDelivery = { @@ -694,6 +782,11 @@ type ContinuousDelivery = { end: number; appliedThrough: number; targeting: MechanicOutcomeTargeting | null; + probabilityPercent: number | null; + probabilityRoll: number | null; +}; +type DeterministicRandom = { + next: () => number; }; type ReducerState = { count: number; @@ -711,10 +804,33 @@ const DEFAULT_TARGET_ENTITY = 'target'; const EPSILON = 0.000_001; const finiteNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value); +const objectValue = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); const formatNumber = (value: number) => Number.isInteger(value) ? String(value) : String(Number(value.toFixed(3))); const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); +function hashSeed(value: string) { + let hash = 2_166_136_261; + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 16_777_619); + } + return hash >>> 0; +} + +function deterministicRandom(seed: number): DeterministicRandom { + let state = seed >>> 0; + return { + next: () => { + state = (state + 0x6d2b79f5) >>> 0; + let value = state; + value = Math.imul(value ^ (value >>> 15), value | 1); + value ^= value + Math.imul(value ^ (value >>> 7), value | 61); + return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296; + }, + }; +} function getPath(source: unknown, path: string): unknown { let current = source; for (const segment of path.split('.').filter(Boolean)) { @@ -793,6 +909,16 @@ function resolveReference( return context.combat?.attributes?.[reference.attributeId]; case 'equipment': return getPath(context.equipment, reference.path); + case 'assigned_ability': { + const remaining = Math.max( + 0, + (context.assignedAbility.expiresAt ?? context.event.at) - + context.event.at, + ); + if (reference.path === 'active') + return context.assignedAbility.active && remaining > EPSILON; + return remaining; + } case 'reducer': return context.reducerValue; case 'pool': @@ -1106,6 +1232,25 @@ function applyMutation( weight = 1, ): MutationRecord { if (mutation.kind === 'none') return { kind: 'none' }; + if (mutation.kind === 'extend_duration') { + const before = Math.max(0, (context.assignedAbility.expiresAt ?? at) - at); + if (!context.assignedAbility.active || before <= EPSILON) { + context.assignedAbility.active = false; + context.assignedAbility.expiresAt = null; + return { kind: mutation.kind }; + } + const seconds = numericValue(mutation.seconds, context); + if (seconds <= 0) return { kind: mutation.kind, before, after: before }; + context.assignedAbility.expiresAt = + (context.assignedAbility.expiresAt as number) + + seconds * clamp(weight, 0, 1); + return { + kind: mutation.kind, + before, + after: Math.max(0, context.assignedAbility.expiresAt - at), + }; + } + if (mutation.kind === 'start_cooldown_on_max') return { kind: mutation.kind }; const definition = context.poolDefinitions.get(mutation.pool); if (!definition) return { kind: mutation.kind, pool: mutation.pool }; const mutationWeight = clamp(weight, 0, 1); @@ -1169,6 +1314,45 @@ function applyMutation( }; } +function startCooldownOnMax( + mutation: Extract, + context: EvaluationContext, + priorMutations: MutationRecord[], + at: number, +): MutationRecord { + const stackDefinition = context.poolDefinitions.get(mutation.stackPool); + const cooldownDefinition = context.poolDefinitions.get(mutation.cooldownPool); + const reachedMaximum = priorMutations.some( + (record) => + record.pool === mutation.stackPool && + record.before !== undefined && + record.after !== undefined && + record.before < (stackDefinition?.max ?? Number.POSITIVE_INFINITY) && + record.after >= (stackDefinition?.max ?? Number.POSITIVE_INFINITY), + ); + if (!reachedMaximum || !cooldownDefinition) + return { kind: mutation.kind, pool: mutation.cooldownPool }; + const entity = + cooldownDefinition.scope === 'agent' + ? AGENT_ENTITY + : context.event.targetId; + if (!entity) return { kind: mutation.kind, pool: mutation.cooldownPool }; + const changed = setPoolValue( + cooldownDefinition, + entity, + 1, + at, + context.pools, + ); + return { + kind: mutation.kind, + pool: mutation.cooldownPool, + entityId: entity, + before: changed.before, + after: changed.after, + }; +} + function outcomeState( outcome: MechanicOutcome, context: EvaluationContext, @@ -1219,6 +1403,31 @@ function resolveOutcomeValue( return ((combat.weaponBaseDamage ?? 0) * value) / 100; } +function outcomeTargetIds( + clause: MechanicClause, + outcome: MechanicOutcome, + context: EvaluationContext, +): Array | null { + if (outcome.targeting?.kind !== 'event_target_area') { + return [context.event.targetId ?? null]; + } + const sample = context.event.areaTargetSample; + if (!context.event.targetId || !sample) { + const error = `Clause "${clause.id}" outcome "${outcome.id}" requires an authoritative event-target area sample.`; + if (!context.runtimeErrors.includes(error)) + context.runtimeErrors.push(error); + return null; + } + return sample.validTargets + .filter( + (target) => + target.population === 'enemy' && + target.distanceMeters <= outcome.targeting!.radiusMeters, + ) + .map((target) => target.entityId) + .sort((left, right) => (left < right ? -1 : left > right ? 1 : 0)); +} + function outcomeResource(outcome: MechanicOutcome): MechanicResource { return outcome.resource ?? 'health'; } @@ -1328,6 +1537,21 @@ function emitOutcomes( }); for (const outcome of eligible) { const { effective: current } = stateValues(outcome); + const probabilityPercent = outcome.probabilityPercent ?? null; + const probabilityRoll = + probabilityPercent === null + ? null + : context.mode === 'maximum' + ? 0 + : context.random.next() * 100; + if ( + probabilityPercent !== null && + (probabilityPercent <= 0 || + (context.mode !== 'maximum' && + (probabilityRoll as number) >= probabilityPercent)) + ) { + continue; + } const baseValue = numericValue(outcome.value, context); const value = outcome.perStack ? baseValue * current : baseValue; const resolvedValue = resolveOutcomeValue(outcome, value, context); @@ -1361,6 +1585,8 @@ function emitOutcomes( end: at + durationSeconds, appliedThrough: at, targeting: outcome.targeting ?? null, + probabilityPercent, + probabilityRoll, }); continue; } @@ -1382,33 +1608,39 @@ function emitOutcomes( ) { appliedValue = 0; } + const targetIds = outcomeTargetIds(clause, outcome, context); + if (!targetIds) continue; const emittedAt = outcome.kind === 'weapon_reload' ? at + Math.max(0, appliedValue) : at; - emissions.push({ - clauseId: clause.id, - outcomeId: outcome.id, - at: emittedAt, - targetId: context.event.targetId ?? null, - phase: outcome.phase, - kind: outcome.kind, - scope: outcome.scope, - resource, - attributeId: outcome.attributeId ?? null, - basis: outcome.basis, - combatTiming: outcome.combatTiming ?? 'current', - value, - resolvedValue, - appliedValue, - weight, - stateValue: current, - combatBefore, - combatAfter: copyCombat(context.combat), - delivery: 'instant', - deliveryStart: null, - deliveryEnd: null, - totalResolvedValue: resolvedValue * weight, - targeting: outcome.targeting ?? null, - }); + for (const targetId of targetIds) { + emissions.push({ + clauseId: clause.id, + outcomeId: outcome.id, + at: emittedAt, + targetId, + phase: outcome.phase, + kind: outcome.kind, + scope: outcome.scope, + resource, + attributeId: outcome.attributeId ?? null, + basis: outcome.basis, + combatTiming: outcome.combatTiming ?? 'current', + value, + resolvedValue, + appliedValue, + weight, + stateValue: current, + combatBefore, + combatAfter: copyCombat(context.combat), + delivery: 'instant', + deliveryStart: null, + deliveryEnd: null, + totalResolvedValue: resolvedValue * weight, + targeting: outcome.targeting ?? null, + probabilityPercent, + probabilityRoll, + }); + } } return emissions; } @@ -1489,6 +1721,8 @@ function advanceContinuousDeliveries( deliveryEnd: delivery.end, totalResolvedValue: delivery.totalResolvedValue, targeting: delivery.targeting, + probabilityPercent: delivery.probabilityPercent, + probabilityRoll: delivery.probabilityRoll, }); } for (let index = deliveries.length - 1; index >= 0; index -= 1) { @@ -1507,7 +1741,11 @@ function expirePools( definitions: Map, combat: MechanicCombatContext | null, equipment: MechanicEquipmentContext | null, + assignedAbility: RuntimeAssignedAbility, + mode: CalculationMode, + random: DeterministicRandom, continuousDeliveries: ContinuousDelivery[], + runtimeErrors: string[], ): MechanicOutcomeEmission[] { const expired = [...pools.entries()] .filter( @@ -1561,6 +1799,10 @@ function expirePools( combat, preEventCombat, equipment, + assignedAbility, + mode, + random, + runtimeErrors, }; const before = new Map([[state.pool, state.value]]); for (const clause of clauses) { @@ -1628,6 +1870,7 @@ function invalidEvaluation( finalCombat: null, equipment: null, estimatedActivationCount: 0, + randomSeed: null, script: formatMechanicProgram(program), }; } @@ -1662,6 +1905,12 @@ function validateReference( errors.push(`${path} requires an Attribute ID.`); if (reference.source === 'equipment' && !reference.path.trim()) errors.push(`${path} requires an equipment path.`); + if ( + reference.source === 'assigned_ability' && + !['active', 'remainingDurationSeconds'].includes(reference.path) + ) { + errors.push(`${path} references an unsupported assigned-ability field.`); + } } function validateStrictEventReferences( @@ -1671,14 +1920,14 @@ function validateStrictEventReferences( path: string, ) { const definition = MECHANIC_EVENT_REGISTRY[eventKind]; - if (!definition?.strictFields || value === null || typeof value !== 'object') - return; + if (!definition || value === null || typeof value !== 'object') return; if ( 'source' in value && value.source === 'event' && 'path' in value && typeof value.path === 'string' && - !definition.fields?.some((field) => field.path === value.path) + !definition.fields?.some((field) => field.path === value.path) && + definition.strictFields ) { errors.push( `${path} references event field "${value.path}" that is not defined for ${definition.label}.`, @@ -1689,6 +1938,21 @@ function validateStrictEventReferences( } } +function containsEventReference(value: unknown, path: string): boolean { + if (!value || typeof value !== 'object') return false; + if ( + 'source' in value && + value.source === 'event' && + 'path' in value && + value.path === path + ) { + return true; + } + return Object.values(value).some((child) => + containsEventReference(child, path), + ); +} + function validateStrictEventPredicate( predicate: Predicate | undefined, eventKind: MechanicEventKind, @@ -1696,8 +1960,16 @@ function validateStrictEventPredicate( path: string, ) { const definition = MECHANIC_EVENT_REGISTRY[eventKind]; - if (!definition?.strictFields || !predicate) return; + if (!definition || !predicate) return; if (predicate.kind === 'not') { + if ( + eventKind === 'weapon_hit' && + containsEventReference(predicate.predicate, 'values.aiming') + ) { + errors.push( + `${path} must compare Aiming down sights explicitly instead of negating missing evidence.`, + ); + } validateStrictEventPredicate( predicate.predicate, eventKind, @@ -1727,7 +1999,17 @@ function validateStrictEventPredicate( : null; const scalar = eventReference === predicate.left ? predicate.right : predicate.left; - if (!eventReference || typeof scalar === 'object') return; + if (!eventReference) return; + if ( + eventKind === 'weapon_hit' && + eventReference.path === 'values.aiming' && + (predicate.operator !== 'eq' || typeof scalar !== 'boolean') + ) { + errors.push( + `${path} must compare Aiming down sights explicitly with true or false.`, + ); + } + if (typeof scalar === 'object') return; const field = definition.fields?.find( (item) => item.path === eventReference.path, ); @@ -1746,7 +2028,7 @@ function validateMechanicEvents(events: MechanicEvent[]) { const errors: string[] = []; for (const [index, event] of events.entries()) { const definition = MECHANIC_EVENT_REGISTRY[event.kind]; - if (!definition?.strictFields) continue; + if (!definition) continue; for (const field of definition.fields ?? []) { const value = getPath(event, field.path); const fieldPath = `Event ${index + 1} (${definition.label}) ${field.label}`; @@ -1781,10 +2063,119 @@ function validateMechanicEvents(events: MechanicEvent[]) { `Event ${index + 1} (Extra Health changed) status must match its current Extra Health.`, ); } + const sample = event.areaTargetSample as unknown; + if (sample === undefined) continue; + const samplePath = `Event ${index + 1} (${definition.label}) area-target sample`; + if ( + event.kind !== 'weapon_hit' || + !objectValue(sample) || + sample.center !== 'event_target' || + !Array.isArray(sample.validTargets) || + typeof event.targetId !== 'string' || + !event.targetId.trim() + ) { + errors.push( + `${samplePath} requires a weapon-hit event target and event-target-centered valid targets.`, + ); + continue; + } + if (sample.validTargets.length > MAX_MECHANIC_AREA_TARGETS) { + errors.push( + `${samplePath} cannot contain more than ${MAX_MECHANIC_AREA_TARGETS} valid targets.`, + ); + continue; + } + const seen = new Set(); + let hasCenterTarget = false; + for (const [targetIndex, rawTarget] of sample.validTargets.entries()) { + const targetPath = `${samplePath} target ${targetIndex + 1}`; + if ( + !objectValue(rawTarget) || + typeof rawTarget.entityId !== 'string' || + !rawTarget.entityId.trim() || + !['ally', 'enemy'].includes(String(rawTarget.population)) || + !finiteNumber(rawTarget.distanceMeters) || + rawTarget.distanceMeters < 0 + ) { + errors.push( + `${targetPath} requires an entity ID, ally or enemy population, and a non-negative distance.`, + ); + continue; + } + if (seen.has(rawTarget.entityId)) { + errors.push(`${targetPath} duplicates entity "${rawTarget.entityId}".`); + } + seen.add(rawTarget.entityId); + if ( + rawTarget.entityId === event.targetId && + rawTarget.population === 'enemy' && + rawTarget.distanceMeters === 0 + ) { + hasCenterTarget = true; + } + } + if (!hasCenterTarget) { + errors.push( + `${samplePath} must include the event target as an enemy at 0 meters.`, + ); + } } return errors; } +export function mechanicOutcomeTargetingError(outcome: { + kind: MechanicOutcome['kind']; + scope: MechanicOutcomeScope; + phase: OutcomePhase; + targeting?: unknown; +}): string | null { + const targeting = outcome.targeting; + if (targeting === undefined) return null; + if (!objectValue(targeting)) return 'has invalid radius targeting.'; + if (targeting.kind === 'within_radius') { + if ( + !['allies', 'enemies'].includes(String(targeting.population)) || + !finiteNumber(targeting.radiusMeters) || + targeting.radiusMeters <= 0 + ) { + return 'has invalid radius targeting.'; + } + if ( + targeting.population === 'allies' && + outcome.scope !== 'allies' && + outcome.scope !== 'self_and_allies' + ) { + return 'ally radius targeting requires allies or self-and-allies scope.'; + } + if (targeting.population === 'enemies' && outcome.scope !== 'target') { + return 'enemy radius targeting requires target scope.'; + } + return null; + } + if (targeting.kind !== 'event_target_area') { + return 'has invalid radius targeting.'; + } + if ( + Object.keys(targeting).some( + (key) => + !['kind', 'center', 'validTargets', 'radiusMeters'].includes(key), + ) || + targeting.center !== 'event_target' || + targeting.validTargets !== 'enemies' || + !finiteNumber(targeting.radiusMeters) || + targeting.radiusMeters <= 0 + ) { + return 'has invalid event-target area damage.'; + } + if (outcome.kind !== 'direct_damage' || outcome.scope !== 'target') { + return 'event-target area damage requires target-scoped direct damage.'; + } + if (outcome.phase !== 'on_event' && outcome.phase !== 'after_event') { + return 'event-target area damage requires an on-event or after-event phase.'; + } + return null; +} + function visitExpression( expression: NumericExpression, pools: Set, @@ -1974,6 +2365,11 @@ function programCombatRequirements(program: MechanicProgram) { ) equipment = true; for (const mutation of clause.mutations) { + if (mutation.kind === 'extend_duration') { + if (expressionUsesCombat(mutation.seconds)) required = true; + if (expressionUsesEquipment(mutation.seconds)) equipment = true; + continue; + } if ( 'value' in mutation && mutation.value !== undefined && @@ -2137,6 +2533,22 @@ export function validateMechanicProgram( ) { errors.push(`${path} cooldown must be greater than 0 seconds.`); } + if ( + clause.procChancePercent !== undefined && + (!finiteNumber(clause.procChancePercent) || + clause.procChancePercent < 0 || + clause.procChancePercent > 100) + ) { + errors.push(`${path} proc chance must be from 0 through 100 percent.`); + } + if ( + clause.requiresAssignedAbilityActive !== undefined && + typeof clause.requiresAssignedAbilityActive !== 'boolean' + ) { + errors.push( + `${path} assigned-ability activity requirement must be a boolean.`, + ); + } const reducer = clause.reducer; if ( reducer.kind === 'count' && @@ -2237,6 +2649,39 @@ export function validateMechanicProgram( for (const [mutationIndex, mutation] of clause.mutations.entries()) { if (mutation.kind === 'none') continue; const mutationPath = `${path} mutation ${mutationIndex + 1}`; + if (mutation.kind === 'extend_duration') { + if (mutation.target !== 'assigned_ability') + errors.push(`${mutationPath} can only extend the assigned ability.`); + visitExpression( + mutation.seconds, + poolKeys, + inputKeys, + errors, + `${mutationPath} duration`, + ); + continue; + } + if (mutation.kind === 'start_cooldown_on_max') { + const stack = (program.pools ?? []).find( + (pool) => pool.key === mutation.stackPool, + ); + const cooldown = (program.pools ?? []).find( + (pool) => pool.key === mutation.cooldownPool, + ); + if (!stack?.max || stack.max <= 0) + errors.push(`${mutationPath} requires a capped stack pool.`); + if ( + !cooldown || + cooldown.scope !== 'agent' || + !cooldown.expiresAfterSeconds + ) + errors.push( + `${mutationPath} requires an expiring Agent cooldown pool.`, + ); + if (mutation.stackPool === mutation.cooldownPool) + errors.push(`${mutationPath} stack and cooldown pools must differ.`); + continue; + } if (!poolKeys.has(mutation.pool)) errors.push( `${mutationPath} references unknown pool "${mutation.pool}".`, @@ -2320,30 +2765,15 @@ export function validateMechanicProgram( `${outcomePath} can only target the inactive weapon with a weapon reload.`, ); } - if (outcome.targeting !== undefined) { - if ( - outcome.targeting.kind !== 'within_radius' || - !['allies', 'enemies'].includes(outcome.targeting.population) || - !finiteNumber(outcome.targeting.radiusMeters) || - outcome.targeting.radiusMeters <= 0 - ) { - errors.push(`${outcomePath} has invalid radius targeting.`); - } else if ( - outcome.targeting.population === 'allies' && - outcome.scope !== 'allies' && - outcome.scope !== 'self_and_allies' - ) { - errors.push( - `${outcomePath} ally radius targeting requires allies or self-and-allies scope.`, - ); - } else if ( - outcome.targeting.population === 'enemies' && - outcome.scope !== 'target' - ) { - errors.push( - `${outcomePath} enemy radius targeting requires target scope.`, - ); - } + const targetingError = mechanicOutcomeTargetingError(outcome); + if (targetingError) errors.push(`${outcomePath} ${targetingError}`); + if ( + outcome.targeting?.kind === 'event_target_area' && + clause.when !== 'weapon_hit' + ) { + errors.push( + `${outcomePath} event-target area damage requires a weapon-hit clause.`, + ); } if ( outcome.kind === 'weapon_reload' && @@ -2438,6 +2868,16 @@ export function validateMechanicProgram( errors, `${outcomePath} value`, ); + if ( + outcome.probabilityPercent !== undefined && + (!finiteNumber(outcome.probabilityPercent) || + outcome.probabilityPercent < 0 || + outcome.probabilityPercent > 100) + ) { + errors.push( + `${outcomePath} probability must be from 0 through 100 percent.`, + ); + } } validateStrictEventReferences( clause.outcomes, @@ -2465,6 +2905,14 @@ export function evaluateMechanicProgram( ) { scenarioErrors.push('Encounter seconds must be greater than 0.'); } + if ( + scenario.randomSeed !== undefined && + (!Number.isInteger(scenario.randomSeed) || + scenario.randomSeed < 0 || + scenario.randomSeed > 0xffff_ffff) + ) { + scenarioErrors.push('Random seed must be an unsigned 32-bit integer.'); + } if (scenario.mode === 'custom') { const definitions = new Map( (program.scenarioInputs ?? []).map((input) => [input.key, input]), @@ -2550,6 +2998,18 @@ export function evaluateMechanicProgram( 'Protocol talent upgrades must be an integer from 0 to 5.', ); } + if (equipmentContext?.assignedAbility) { + const ability = equipmentContext.assignedAbility; + if ( + typeof ability.active !== 'boolean' || + !finiteNumber(ability.remainingDurationSeconds) || + ability.remainingDurationSeconds < 0 + ) { + scenarioErrors.push( + 'Assigned ability state requires an active flag and a non-negative remaining duration.', + ); + } + } if (!validation.ok || scenarioErrors.length) { return invalidEvaluation(program, scenario, [ ...(validation.ok ? [] : validation.errors), @@ -2571,11 +3031,23 @@ export function evaluateMechanicProgram( (program.pools ?? []).map((pool) => [pool.key, pool]), ); const pools = initialPools(poolDefinitions, scenario.mode); + const randomSeed = scenario.randomSeed ?? hashSeed(program.id); + const random = deterministicRandom(randomSeed); + const assignedAbility: RuntimeAssignedAbility = { + active: Boolean(equipment?.assignedAbility?.active), + expiresAt: + equipment?.assignedAbility?.active && + finiteNumber(equipment.assignedAbility.remainingDurationSeconds) && + equipment.assignedAbility.remainingDurationSeconds > 0 + ? equipment.assignedAbility.remainingDurationSeconds + : null, + }; const reducerStates = new Map(); const cooldowns = new Map(); const emissions: MechanicEmission[] = []; const outcomes: MechanicOutcomeEmission[] = []; const continuousDeliveries: ContinuousDelivery[] = []; + const runtimeErrors: string[] = []; if (scenario.mode === 'maximum') { for (const clause of program.clauses) { if ( @@ -2600,8 +3072,20 @@ export function evaluateMechanicProgram( combat, preEventCombat: copyCombat(combat), equipment, + assignedAbility, + mode: scenario.mode, + random, + runtimeErrors, }; - if (!predicateMatches(clause.if, context)) continue; + if ( + !predicateMatches(clause.if, context) || + (clause.requiresAssignedAbilityActive && + resolveReference( + { source: 'assigned_ability', path: 'active' }, + context, + ) !== true) + ) + continue; const before = new Map( (clause.outcomes ?? []) .map((outcome) => outcome.statePool) @@ -2657,6 +3141,22 @@ export function evaluateMechanicProgram( }, } : rawEvent; + if ( + assignedAbility.expiresAt !== null && + assignedAbility.expiresAt <= event.at + EPSILON + ) { + assignedAbility.active = false; + assignedAbility.expiresAt = null; + } + const activatedDuration = + event.kind === 'skill_deployed' || + event.kind === 'signature_ability_deployed' + ? getPath(event, 'values.assignedAbilityDurationSeconds') + : undefined; + if (finiteNumber(activatedDuration) && activatedDuration > 0) { + assignedAbility.active = true; + assignedAbility.expiresAt = event.at + activatedDuration; + } outcomes.push( ...expirePools( event.at, @@ -2666,7 +3166,11 @@ export function evaluateMechanicProgram( poolDefinitions, combat, equipment, + assignedAbility, + scenario.mode, + random, continuousDeliveries, + runtimeErrors, ), ); outcomes.push( @@ -2684,11 +3188,31 @@ export function evaluateMechanicProgram( combat, preEventCombat, equipment, + assignedAbility, + mode: scenario.mode, + random, + runtimeErrors, }; - const matched = predicateMatches(clause.if, baseContext); + const predicateMatched = + predicateMatches(clause.if, baseContext) && + (!clause.requiresAssignedAbilityActive || + resolveReference( + { source: 'assigned_ability', path: 'active' }, + baseContext, + ) === true); const group = reducerGroup(clause.reducer, baseContext); const stateKey = `${clause.id}\u0000${group}`; if (event.at + EPSILON < (cooldowns.get(stateKey) ?? 0)) continue; + let matched = predicateMatched; + if ( + matched && + clause.procChancePercent !== undefined && + (clause.procChancePercent <= 0 || + (scenario.mode !== 'maximum' && + random.next() * 100 >= clause.procChancePercent)) + ) { + matched = false; + } const reducerState = reducerStates.get(stateKey) ?? freshReducerState(); reducerStates.set(stateKey, reducerState); const reduced = reduceEvent( @@ -2729,9 +3253,19 @@ export function evaluateMechanicProgram( reduction.at, scenario.encounterSeconds, ); - const mutationRecords = clause.mutations.map((mutation) => - applyMutation(mutation, reduction.at, context, weight), - ); + const mutationRecords: MutationRecord[] = []; + for (const mutation of clause.mutations) { + mutationRecords.push( + mutation.kind === 'start_cooldown_on_max' + ? startCooldownOnMax( + mutation, + context, + mutationRecords, + reduction.at, + ) + : applyMutation(mutation, reduction.at, context, weight), + ); + } emissions.push({ clauseId: clause.id, at: reduction.at, @@ -2771,7 +3305,11 @@ export function evaluateMechanicProgram( poolDefinitions, combat, equipment, + assignedAbility, + scenario.mode, + random, continuousDeliveries, + runtimeErrors, ), ); outcomes.push( @@ -2781,6 +3319,9 @@ export function evaluateMechanicProgram( combat, ), ); + if (runtimeErrors.length) { + return invalidEvaluation(program, scenario, runtimeErrors); + } return { status: 'OK', errors: [], @@ -2806,6 +3347,7 @@ export function evaluateMechanicProgram( (total, emission) => total + emission.weight, 0, ), + randomSeed, script: formatMechanicProgram(program), }; } @@ -2836,6 +3378,8 @@ function formatReference(reference: ValueReference): string { return `ATTRIBUTE[${reference.attributeId}]`; case 'equipment': return `EQUIPMENT.${reference.path}`; + case 'assigned_ability': + return `ASSIGNED ABILITY.${reference.path}`; case 'reducer': return 'REDUCER VALUE'; case 'pool': @@ -2933,6 +3477,10 @@ function formatReducer(reducer: Reducer): string[] { function formatMutation(mutation: ClauseMutation): string { if (mutation.kind === 'none') return 'NO STATE CHANGE'; + if (mutation.kind === 'extend_duration') + return `EXTEND ASSIGNED ABILITY DURATION BY ${formatExpression(mutation.seconds)}s`; + if (mutation.kind === 'start_cooldown_on_max') + return `START ${mutation.cooldownPool} WHEN ${mutation.stackPool} REACHES MAX`; if (mutation.kind === 'transfer') { const value = mutation.value === undefined || mutation.value === 'all' @@ -2976,15 +3524,22 @@ function formatOutcome(outcome: MechanicOutcome): string { outcome.delivery === 'continuous' ? `; DELIVER TOTAL CONTINUOUSLY OVER ${formatNumber(outcome.durationSeconds ?? 0)}s` : ''; - const targeting = outcome.targeting - ? ` TO ${outcome.targeting.population.toUpperCase()} WITHIN ${formatNumber(outcome.targeting.radiusMeters)}m` - : ''; + const targeting = + outcome.targeting?.kind === 'event_target_area' + ? ` AS EXPLOSION CENTERED ON EVENT TARGET; APPLY TO VALID ENEMIES WITHIN ${formatNumber(outcome.targeting.radiusMeters)}m` + : outcome.targeting + ? ` TO ${outcome.targeting.population.toUpperCase()} WITHIN ${formatNumber(outcome.targeting.radiusMeters)}m` + : ''; + const probability = + outcome.probabilityPercent === undefined + ? '' + : `; ${formatNumber(outcome.probabilityPercent)}% PROC`; if (outcome.kind === 'weapon_reload') { const target = outcome.scope === 'equipped_weapon' ? 'EQUIPPED' : 'INACTIVE'; return `${outcome.phase.toUpperCase()}: RELOAD ${target} WEAPON AFTER ${formatExpression(outcome.value)}s // ${outcome.label}`; } - return `${outcome.phase.toUpperCase()}: APPLY ${formatExpression(outcome.value)} ${outcome.basis.toUpperCase()}${resource}${attribute}${perStack}${stackBand}${timing}${targeting}${delivery} // ${outcome.label}`; + return `${outcome.phase.toUpperCase()}: APPLY ${formatExpression(outcome.value)} ${outcome.basis.toUpperCase()}${resource}${attribute}${perStack}${stackBand}${timing}${targeting}${delivery}${probability} // ${outcome.label}`; } export function formatMechanicProgram(program: MechanicProgram): string { @@ -3012,6 +3567,10 @@ export function formatMechanicProgram(program: MechanicProgram): string { if (clause.cooldownSeconds) lines.push(`COOLDOWN ${formatNumber(clause.cooldownSeconds)}s`); if (clause.weight) lines.push(`WEIGHT ${formatExpression(clause.weight)}`); + if (clause.procChancePercent !== undefined) + lines.push(`PROC CHANCE ${formatNumber(clause.procChancePercent)}%`); + if (clause.requiresAssignedAbilityActive) + lines.push('REQUIRES ASSIGNED ABILITY ACTIVE'); lines.push(...clause.mutations.map(formatMutation)); lines.push(...(clause.outcomes ?? []).map(formatOutcome)); } diff --git a/src/lib/components/ArticleContent.svelte b/src/lib/components/ArticleContent.svelte index 6fa6d32..3b2bd81 100644 --- a/src/lib/components/ArticleContent.svelte +++ b/src/lib/components/ArticleContent.svelte @@ -24,6 +24,16 @@ function link(mark: ArticleMark) { return mark.type === 'link' ? mark.attrs?.href ?? '#' : '#'; } + + function orderedListStart(node: ArticleNode) { + const start = Number(node.attrs?.start ?? 1); + return Number.isInteger(start) && start > 1 ? start : undefined; + } + + function orderedListType(node: ArticleNode): 'a' | 'A' | 'i' | 'I' | undefined { + const type = node.attrs?.type; + return ['a', 'A', 'i', 'I'].includes(String(type)) ? type as 'a' | 'A' | 'i' | 'I' : undefined; + } {#snippet renderText(text: string, marks: ArticleMark[], index = 0)} @@ -32,7 +42,7 @@ {#if mark.type === 'bold'}{@render renderText(text, marks, index + 1)} {:else if mark.type === 'italic'}{@render renderText(text, marks, index + 1)} {:else if mark.type === 'code'}{@render renderText(text, marks, index + 1)} - {:else}{@render renderText(text, marks, index + 1)}{/if} + {:else}{@render renderText(text, marks, index + 1)}{/if} {/if} {/snippet} @@ -46,7 +56,7 @@ {:else if node.type === 'heading' && Number(node.attrs?.level) === 2}

{@render children(node)}

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

{@render children(node)}

{:else if node.type === 'bulletList'}
    {@render children(node)}
- {:else if node.type === 'orderedList'}
    {@render children(node)}
+ {:else if node.type === 'orderedList'}
    {@render children(node)}
{:else if node.type === 'listItem'}
  • {@render children(node)}
  • {:else if node.type === 'blockquote'}
    {@render children(node)}
    {:else if node.type === 'horizontalRule'}
    diff --git a/src/lib/components/ArticlePage.svelte b/src/lib/components/ArticlePage.svelte index 06d8795..d4a384a 100644 --- a/src/lib/components/ArticlePage.svelte +++ b/src/lib/components/ArticlePage.svelte @@ -1,7 +1,7 @@ -
    +
    {#if item.renderUrl}{/if}
    @@ -31,13 +44,38 @@ {#if item.headerBadges?.length || item.headerBadge || item.headerMetaIcon}
    {#if item.headerMetaIcon}{/if} - {#each item.headerBadges ?? [] as badge}{badge}{/each} - {#if item.headerBadge}{item.headerBadge}{/if} + {#each item.headerBadges ?? [] as badge}{badge}{/each} + {#if item.headerBadge}{item.headerBadge}{/if}
    {/if}
    {#if item.badges?.length}
    {#each item.badges as badge}{badge.label}{/each}
    {/if} - + {#if item.protocolPreview} +
    +
    {item.protocolPreview.primaryStat}{Number.isFinite(protocolPrimaryValue()) ? valueWithUnit(protocolPrimaryValue(), item.protocolPreview.primaryUnit) : 'NOT CONFIGURED'}
    + {#if item.protocolPreview.talentDescription} +

    {talentDescription()}

    + {/if} +
    {#if item.protocolPreview.talentDescription}
    {#each [1, 2, 3, 4, 5] as upgrade}{/each}
    {/if}
    +
    + {:else} + + {/if} + {#if item.weaponModSlots} +
    +
    MOD SLOTS
    + {#if item.weaponModSlots.length} +
      + {#each item.weaponModSlots as modSlot} +
    • + {#if modSlot.iconUrl}{modSlot.slot}{:else}{modSlot.slot}{/if} + {#if modSlot.unlockedAtTierOne}T1+{/if} +
    • + {/each} +
    + {:else}

    MOD SLOT DATA NOT CONFIGURED

    {/if} +
    + {/if} {#if item.lines?.length}
    {#each item.lines as line}
    {line.label}{line.value}
    {/each}
    {/if} @@ -53,7 +91,7 @@
    TIER I

    {item.description ?? 'Description is not available.'}

    TIER II

    {item.secondaryDescription ?? 'Description is not available.'}

    - {:else} + {:else if !item.protocolPreview} {#if item.description}

    {item.description}

    {/if} {#if item.secondaryDescription}

    {item.secondaryDescription}

    {/if} {/if} diff --git a/src/lib/components/ModSecondaryPoolEditor.svelte b/src/lib/components/ModSecondaryPoolEditor.svelte new file mode 100644 index 0000000..014db89 --- /dev/null +++ b/src/lib/components/ModSecondaryPoolEditor.svelte @@ -0,0 +1,125 @@ + + +
    +
    +
    + SHARED {kind.toUpperCase()} SECONDARY POOL + ONE CANONICAL POOL FOR EVERY REGULAR {kind.toUpperCase()} +
    + +
    +
    + {#each MOD_QUALITIES as quality} + {quality.toUpperCase()}{MOD_SECONDARY_CAPACITY[quality]}SECONDARY {MOD_SECONDARY_CAPACITY[quality] === 1 ? 'ROLL' : 'ROLLS'} + {/each} + ATTRIBUTE POOL{pool.length}SHARED OPTIONS +
    +
    + +{#if editing} +
    +
    +

    SHARED CONFIGURATION

    {kind.toUpperCase()} SECONDARY ATTRIBUTES

    + +
    + + + + +

    Standard quality has no secondary roll. Specialized selects one option; Superior and High-end select two distinct options. Ranges scale linearly from Level 1 to Level 20.

    +
    +
    AVAILABLE ATTRIBUTESSPECIALIZED · SUPERIOR · HIGH-END LEVEL ENDPOINTS
    + +
    +
    + {#each draft as range, index} +
    + {String(index + 1).padStart(2, '0')} + itemIndex !== index).map((item) => item.statId)} + bind:value={range.statId} + /> + {#each MOD_SECONDARY_QUALITIES as quality} +
    + {quality.toUpperCase()} + + +
    + {/each} + +
    + {/each} +
    + {#if error}

    {error}

    {/if} +
    +
    +
    +{/if} diff --git a/src/lib/components/ProposalChangePreview.svelte b/src/lib/components/ProposalChangePreview.svelte index 7a2b01c..35e1682 100644 --- a/src/lib/components/ProposalChangePreview.svelte +++ b/src/lib/components/ProposalChangePreview.svelte @@ -29,8 +29,6 @@ gear_max: 'gearMax', weapon_min: 'weaponMin', weapon_max: 'weaponMax', - skill_chip_min: 'chipMin', - skill_chip_max: 'chipMax', cap_value: 'capValue' }; return proposed[names[key] ?? key]; @@ -46,6 +44,8 @@ ); const specializationRecord = $derived((currentSnapshot?.record ?? null) as Record | null); const skillChipRecord = $derived((currentSnapshot?.record ?? null) as Record | null); + const currentSharedSkillChipProfiles = $derived((Array.isArray(currentSnapshot) ? currentSnapshot : []) as any[]); + const proposedSharedSkillChipProfiles = $derived((jsonValue(proposed.profiles) ?? []) as any[]); const skillChipFields = $derived([ { label: 'NAME', current: skillChipRecord?.name, proposed: proposed.name }, { label: 'SPECIALIZATION ABILITY', current: skillChipRecord?.specialization_ability_id, proposed: proposed.abilityId }, @@ -256,7 +256,7 @@ {#each [['type','TYPE'],['category','CATEGORY'],['calculation_type','CALCULATION'],['unit','UNIT']] as field}
    {field[1]}{shown(attributeValue('current', field[0]))}
    {/each} - {#each [['gear','GEAR'],['weapon','WEAPON'],['skill_chip','SKILL CHIP']] as source} + {#each [['gear','GEAR'],['weapon','WEAPON']] as source}
    {source[1]} RANGE · OPTIONAL
    {shown(attributeValue('current', `${source[0]}_min`))}TO{shown(attributeValue('current', `${source[0]}_max`))}
    {/each}
    OPTIONAL CAP{shown(attributeValue('current', 'cap_value'))}
    @@ -273,13 +273,24 @@ {#each [['type','TYPE'],['category','CATEGORY'],['calculation_type','CALCULATION'],['unit','UNIT']] as field}
    {field[1]}{shown(attributeValue('proposed', field[0]))}
    {/each} - {#each [['gear','GEAR'],['weapon','WEAPON'],['skill_chip','SKILL CHIP']] as source} + {#each [['gear','GEAR'],['weapon','WEAPON']] as source}
    {source[1]} RANGE · OPTIONAL
    {shown(attributeValue('proposed', `${source[0]}_min`))}TO{shown(attributeValue('proposed', `${source[0]}_max`))}
    {/each}
    OPTIONAL CAP{shown(attributeValue('proposed', 'cap_value'))}
    + {:else if module === 'skillchips' && ['savePrimaryProfile','saveSecondaryPool'].includes(action)} +
    +
    +
    CURRENT LIVE · SHARED {action === 'savePrimaryProfile' ? 'PRIMARY PROFILE' : 'SECONDARY POOL'}BEFORE
    +
    {#each currentSharedSkillChipProfiles as profile}

    {profile.statId} · {Object.entries(profile.values ?? {}).map(([quality, range]: [string, any]) => `${quality.toUpperCase()} ${shown(range.min)}–${shown(range.max)}`).join(' · ')}

    {:else}

    SHARED PROFILE NOT CONFIGURED

    {/each}
    +
    +
    +
    PROPOSED · SHARED {action === 'savePrimaryProfile' ? 'PRIMARY PROFILE' : 'SECONDARY POOL'}AFTER
    +
    {#each proposedSharedSkillChipProfiles as profile}

    {profile.statId} · {Object.entries(profile.values ?? {}).map(([quality, range]: [string, any]) => `${quality.toUpperCase()} ${shown(range.min)}–${shown(range.max)}`).join(' · ')}

    {/each}
    +
    +
    {:else if module === 'skillchips' && action === 'save'}
    @@ -287,7 +298,7 @@ {#if skillChipRecord}
    {#each skillChipFields as field}
    {field.label}{shown(field.current)}
    {/each} -
    ROLL POOLS{(currentSnapshot?.primaryOptions as any[])?.length ?? 0} PRIMARY · {(currentSnapshot?.secondaryOptions as any[])?.length ?? 0} SECONDARY
    +
    3-PIECE TALENT{currentSnapshot?.talent ? 'ASSIGNED' : 'NOT ASSIGNED'}
    3-PIECE CONDITIONAL MECHANICTYPED RULE
    @@ -301,7 +312,7 @@
    PROPOSED · SKILL CHIPAFTER
    {#each skillChipFields as field}
    {field.label}{shown(field.proposed)}
    {/each} -
    ROLL POOLS{Array.isArray(proposed.primaryStatIds) ? proposed.primaryStatIds.length : Number(Boolean(proposed.primaryStatIds))} PRIMARY · {Array.isArray(proposed.secondaryStatIds) ? proposed.secondaryStatIds.length : Number(Boolean(proposed.secondaryStatIds))} SECONDARY
    +
    3-PIECE TALENT{proposed.hasTalent === '1' ? 'ASSIGNED' : 'NOT ASSIGNED'}
    3-PIECE CONDITIONAL MECHANICTYPED RULE
    diff --git a/src/lib/components/SkillChipSharedProfiles.svelte b/src/lib/components/SkillChipSharedProfiles.svelte new file mode 100644 index 0000000..534821e --- /dev/null +++ b/src/lib/components/SkillChipSharedProfiles.svelte @@ -0,0 +1,128 @@ + + +
    +
    +
    SHARED PRIMARY PROFILEFOUR ABILITY-SCOPED STATS · ONE RANGE PER QUALITY
    +
    + {#each SKILL_CHIP_QUALITIES as quality}{quality.toUpperCase()}{primaryProfiles.filter((profile) => profile.values[quality]).length}OF 4 CONFIGURED{/each} + CALCULATION—NOT YET DEFINED +
    +
    +
    +
    SHARED SECONDARY POOLONE CANONICAL ATTRIBUTE POOL FOR EVERY SKILL CHIP
    +
    + {#each SKILL_CHIP_QUALITIES as quality}{quality.toUpperCase()}{SKILL_CHIP_SECONDARY_CAPACITY[quality]}SECONDARY {SKILL_CHIP_SECONDARY_CAPACITY[quality] === 1 ? 'ROLL' : 'ROLLS'}{/each} + ATTRIBUTE POOL{secondaryPool.length}SHARED OPTIONS +
    +
    +
    + +{#if primaryEditing} +
    +

    SHARED CONFIGURATION

    SKILL CHIP PRIMARY RANGES

    + + +

    Every Skill Chip rolls one of these four ability-scoped primary stats. Configure the direct minimum and maximum for each quality. Current combat calculations do not apply these values.

    +
    + {#each primaryDraft as profile} +
    {stats.find((item) => item.id === profile.statId)?.name ?? 'UNKNOWN ATTRIBUTE'}{stats.find((item) => item.id === profile.statId)?.unit || 'FLAT'} · ABILITY SCOPED
    {#each SKILL_CHIP_QUALITIES as quality}{/each}
    + {/each} +
    + {#if primaryError}

    {primaryError}

    {/if} +
    +
    +
    +{/if} + +{#if secondaryEditing} +
    +

    SHARED CONFIGURATION

    SKILL CHIP SECONDARY POOL

    + + +

    Standard has no secondary roll. Specialized selects one option; Superior and High-end select two distinct options. Skill Chips have no item level, so each quality uses one direct range.

    +
    AVAILABLE ATTRIBUTESSPECIALIZED · SUPERIOR · HIGH-END RANGES
    +
    + {#each secondaryDraft as profile, index} +
    {String(index + 1).padStart(2, '0')} itemIndex !== index).map((item) => item.statId)} bind:value={profile.statId}/>{#each SKILL_CHIP_SECONDARY_QUALITIES as quality}{/each}
    + {/each} +
    + {#if secondaryError}

    {secondaryError}

    {/if} +
    +
    +
    +{/if} diff --git a/src/lib/components/WeaponModSlotIcon.svelte b/src/lib/components/WeaponModSlotIcon.svelte new file mode 100644 index 0000000..f9961b1 --- /dev/null +++ b/src/lib/components/WeaponModSlotIcon.svelte @@ -0,0 +1,29 @@ + + +{#if iconUrl} + {decorative +{/if} + + diff --git a/src/lib/components/WeaponModSlotIconPanel.svelte b/src/lib/components/WeaponModSlotIconPanel.svelte new file mode 100644 index 0000000..c3a4613 --- /dev/null +++ b/src/lib/components/WeaponModSlotIconPanel.svelte @@ -0,0 +1,212 @@ + + +
    +
    +
    + SHARED MOD SLOT ICONS + ONE CANONICAL ICON FOR EACH WEAPON MOD SLOT +
    + {slots.every((slot) => slot.iconUrl) ? 'READY' : 'CONFIG REQUIRED'} +
    +
    + {#each slots as slot} + + {/each} +
    +
    + +{#if editing} +
    +
    +

    SHARED MOD SLOT ARTWORK

    {editing.name.toUpperCase()}

    + +
    + + + + + +
    +
    + + {#if editing.iconUrl} + + {:else} + + {/if} + +
    CANONICAL SLOT{editing.name}

    Weapon cards use this artwork anywhere the slot is available.

    +
    + +
    + {#if error}{/if} +
    +
    +
    +{/if} + + diff --git a/src/lib/components/isac/MechanicProgramEditor.svelte b/src/lib/components/isac/MechanicProgramEditor.svelte index 5d07070..92020bd 100644 --- a/src/lib/components/isac/MechanicProgramEditor.svelte +++ b/src/lib/components/isac/MechanicProgramEditor.svelte @@ -9,6 +9,8 @@ blankConditionalMechanicDraft, currentHealthPercentBandPredicate, eventFieldComparisonPredicate, + mechanicTargetingForMode, + mechanicTargetingMode, parseConditionalMechanicDraft, readCurrentHealthPercentBand, readEventFieldComparison, @@ -21,6 +23,7 @@ type ConditionalMechanicPoolDraft, type EditablePredicateScalar, type EventFieldComparison, + type MechanicTargetingMode, type PredicateComparisonOperator, type StatePoolThresholdComparison } from '$lib/calculations/mechanic-editor'; @@ -182,7 +185,9 @@ } function eventPredicateOperatorOptions(clause: ConditionalMechanicClauseDraft) { - return selectedEventPredicateField(clause)?.type === 'number' + const field = selectedEventPredicateField(clause); + if (field?.path === 'values.aiming') return predicateOperatorOptions.filter((option) => option.value === 'eq'); + return field?.type === 'number' ? predicateOperatorOptions : predicateOperatorOptions.filter((option) => option.value === 'eq' || option.value === 'ne'); } @@ -190,15 +195,21 @@ function setClauseEvent(clauseIndex: number, event: string) { const nextEvent = event as ConditionalMechanicClauseDraft['event']; const clause = rule.clauses[clauseIndex]; + const outcomes = nextEvent === 'weapon_hit' + ? clause.outcomes + : clause.outcomes.map((outcome) => outcome.targeting?.kind === 'event_target_area' + ? { ...outcome, targeting: undefined } + : outcome); const fields = registeredEventFields(nextEvent); const comparison = readEventFieldComparison(clause.if); if (!comparison || !fields.length) { - updateClause(clauseIndex, { event: nextEvent }); + updateClause(clauseIndex, { event: nextEvent, outcomes }); return; } const field = fields[0]; updateClause(clauseIndex, { event: nextEvent, + outcomes, if: eventFieldComparisonPredicate({ path: field.path, operator: 'eq', @@ -403,6 +414,69 @@ updateClause(clauseIndex, { outcomes }); } + function targetingOptions(clause: ConditionalMechanicClauseDraft, outcome: ConditionalMechanicOutcomeDraft) { + if (outcome.scope === 'allies' || outcome.scope === 'self_and_allies') { + return [ + { value: 'none', label: 'NONE' }, + { value: 'allies_within_radius', label: 'ALLIES IN RADIUS' } + ]; + } + if (outcome.scope === 'target') { + return [ + { value: 'none', label: 'NONE' }, + { value: 'enemies_within_radius', label: 'ENEMIES IN RADIUS · LEGACY' }, + ...(outcome.kind === 'direct_damage' && clause.event === 'weapon_hit' + ? [{ value: 'event_target_explosion', label: 'HIT-TARGET EXPLOSION' }] + : []) + ]; + } + return [{ value: 'none', label: 'NONE' }]; + } + + function setOutcomeScope(clauseIndex: number, outcomeIndex: number, scope: MechanicOutcomeScope) { + const outcome = rule.clauses[clauseIndex].outcomes[outcomeIndex]; + const targeting = outcome.targeting; + const compatible = !targeting + || (targeting.kind === 'event_target_area' && scope === 'target' && outcome.kind === 'direct_damage') + || (targeting.kind === 'within_radius' && targeting.population === 'enemies' && scope === 'target') + || (targeting.kind === 'within_radius' && targeting.population === 'allies' && (scope === 'allies' || scope === 'self_and_allies')); + updateOutcome(clauseIndex, outcomeIndex, { scope, targeting: compatible ? targeting : undefined }); + } + + function setOutcomeKind(clauseIndex: number, outcomeIndex: number, kind: ConditionalMechanicOutcomeDraft['kind']) { + const outcome = rule.clauses[clauseIndex].outcomes[outcomeIndex]; + updateOutcome(clauseIndex, outcomeIndex, { + kind, + resource: kind === 'restoration' || kind === 'resource_cost' ? outcome.resource ?? 'health' : undefined, + attributeId: kind === 'attribute_modifier' ? outcome.attributeId ?? '' : undefined, + delivery: kind === 'restoration' ? outcome.delivery : undefined, + durationSeconds: kind === 'restoration' ? outcome.durationSeconds : undefined, + targeting: outcome.targeting?.kind === 'event_target_area' && kind !== 'direct_damage' + ? undefined + : outcome.targeting + }); + } + + function setOutcomeTargeting(clauseIndex: number, outcomeIndex: number, mode: MechanicTargetingMode) { + const outcome = rule.clauses[clauseIndex].outcomes[outcomeIndex]; + updateOutcome(clauseIndex, outcomeIndex, { + targeting: mechanicTargetingForMode(mode, outcome.targeting), + phase: mode === 'event_target_explosion' && outcome.phase !== 'on_event' && outcome.phase !== 'after_event' + ? 'after_event' + : outcome.phase + }); + } + + function phaseOptions(outcome: ConditionalMechanicOutcomeDraft) { + return [ + { value: 'on_event', label: 'ON EVENT' }, + { value: 'after_event', label: 'AFTER EVENT' }, + { value: 'while_active', label: 'WHILE ACTIVE', disabled: outcome.targeting?.kind === 'event_target_area' }, + { value: 'on_enter', label: 'ON ENTER', disabled: outcome.targeting?.kind === 'event_target_area' }, + { value: 'on_exit', label: 'ON EXIT', disabled: outcome.targeting?.kind === 'event_target_area' } + ]; + } + function updateVariant(clauseIndex: number, outcomeIndex: number, variant: string, raw: string) { const outcome = rule.clauses[clauseIndex].outcomes[outcomeIndex]; const value = Number(raw); @@ -684,6 +758,50 @@ }); } + function addAbilityDurationExtension(clauseIndex: number) { + updateClause(clauseIndex, { + mutations: [ + ...rule.clauses[clauseIndex].mutations, + { kind: 'extend_duration', target: 'assigned_ability', seconds: 0.3 } + ] + }); + } + + function addCooldownOnMax(clauseIndex: number) { + const stackPool = rule.pools.find((pool) => pool.max && pool.max > 0)?.key; + const cooldownPool = rule.pools.find((pool) => pool.scope === 'agent' && pool.expiresAfterSeconds)?.key; + if (!stackPool || !cooldownPool || stackPool === cooldownPool) return; + updateClause(clauseIndex, { + mutations: [ + ...rule.clauses[clauseIndex].mutations, + { kind: 'start_cooldown_on_max', stackPool, cooldownPool } + ] + }); + } + + function updateAbilityDurationExtension(clauseIndex: number, mutationIndex: number, raw: string) { + const mutations = [...rule.clauses[clauseIndex].mutations]; + const seconds = Number(raw); + mutations[mutationIndex] = { + kind: 'extend_duration', + target: 'assigned_ability', + seconds: Number.isFinite(seconds) ? seconds : 0 + }; + updateClause(clauseIndex, { mutations }); + } + + function updateCooldownOnMax( + clauseIndex: number, + mutationIndex: number, + patch: { stackPool?: string; cooldownPool?: string } + ) { + const mutations = [...rule.clauses[clauseIndex].mutations]; + const current = mutations[mutationIndex]; + if (current.kind !== 'start_cooldown_on_max') return; + mutations[mutationIndex] = { ...current, ...patch }; + updateClause(clauseIndex, { mutations }); + } + function updateMutation(clauseIndex: number, mutationIndex: number, kind: 'add' | 'set' | 'consume' | 'clear', pool?: string, value?: number) { const mutations = [...rule.clauses[clauseIndex].mutations]; const current = mutations[mutationIndex]; @@ -981,6 +1099,20 @@ {:else if conditionModel(clause) === 'advanced'}

    This saved condition remains active and is preserved unchanged. Use the generated mechanic preview to inspect it.

    {/if} + {#if allowAssignedAbility} +
    + updateClause(clauseIndex,{requiresAssignedAbilityActive:values[0] === 'true' ? true : undefined})} + /> + +
    + {:else} + + {/if}
    -
    STATE CHANGES{clause.mutations.length}
    +
    STATE CHANGES{clause.mutations.length}
    {#if allowAssignedAbility}{/if}
    {#each clause.mutations as mutation, mutationIndex} -
    - updateMutation(clauseIndex,mutationIndex,values[0] as 'add'|'set'|'consume'|'clear')} - /> - ({value:pool.key,label:pool.label.toUpperCase()}))} - value={'pool' in mutation ? mutation.pool : rule.pools[0]?.key ?? ''} - columns={Math.min(4,Math.max(1,rule.pools.length))} - onchange={(values) => updateMutation(clauseIndex,mutationIndex,mutation.kind === 'clear' ? 'clear' : mutation.kind === 'set' || mutation.kind === 'consume' ? mutation.kind : 'add',values[0])} - /> - {#if mutation.kind !== 'clear'} - - {/if} - {#if 'pool' in mutation && rule.pools.find((pool) => pool.key === mutation.pool)?.scope === 'target'} + {#if mutation.kind === 'extend_duration'} +
    +
    OPERATIONEXTEND ASSIGNED ABILITY
    + + +
    + {:else if mutation.kind === 'start_cooldown_on_max'} +
    +
    OPERATIONSTART COOLDOWN ON MAX
    + pool.max && pool.max > 0).map((pool) => ({value:pool.key,label:pool.label.toUpperCase()}))} value={mutation.stackPool} onchange={(values) => updateCooldownOnMax(clauseIndex,mutationIndex,{stackPool:values[0]})}/> + pool.scope === 'agent' && pool.expiresAfterSeconds && pool.key !== mutation.stackPool).map((pool) => ({value:pool.key,label:pool.label.toUpperCase()}))} value={mutation.cooldownPool} onchange={(values) => updateCooldownOnMax(clauseIndex,mutationIndex,{cooldownPool:values[0]})}/> + +
    + {:else} +
    updateMutationEntity(clauseIndex,mutationIndex,values[0] as 'event_target'|'event_actor')} + legend="OPERATION" + name={`${idPrefix}-mutation-kind-${clauseIndex}-${mutationIndex}`} + options={[{value:'add',label:'ADD'},{value:'set',label:'SET'},{value:'consume',label:'CONSUME'},{value:'clear',label:'CLEAR'}]} + value={mutation.kind === 'none' || mutation.kind === 'derive' || mutation.kind === 'transfer' ? 'add' : mutation.kind} + onchange={(values) => updateMutation(clauseIndex,mutationIndex,values[0] as 'add'|'set'|'consume'|'clear')} /> - {/if} - -
    + ({value:pool.key,label:pool.label.toUpperCase()}))} + value={'pool' in mutation ? mutation.pool : rule.pools[0]?.key ?? ''} + columns={Math.min(4,Math.max(1,rule.pools.length))} + onchange={(values) => updateMutation(clauseIndex,mutationIndex,mutation.kind === 'clear' ? 'clear' : mutation.kind === 'set' || mutation.kind === 'consume' ? mutation.kind : 'add',values[0])} + /> + {#if mutation.kind !== 'clear'} + + {/if} + {#if 'pool' in mutation && rule.pools.find((pool) => pool.key === mutation.pool)?.scope === 'target'} + updateMutationEntity(clauseIndex,mutationIndex,values[0] as 'event_target'|'event_actor')} + /> + {/if} + +
    + {/if} {:else}

    This branch emits outcomes without changing shared state.

    {/each} @@ -1049,25 +1196,24 @@
    - updateOutcome(clauseIndex,outcomeIndex,{kind:values[0] as ConditionalMechanicOutcomeDraft['kind'],resource:values[0] === 'restoration' || values[0] === 'resource_cost' ? outcome.resource ?? 'health' : undefined,attributeId:values[0] === 'attribute_modifier' ? outcome.attributeId ?? '' : undefined,delivery:values[0] === 'restoration' ? outcome.delivery : undefined,durationSeconds:values[0] === 'restoration' ? outcome.durationSeconds : undefined})}/> + setOutcomeKind(clauseIndex,outcomeIndex,values[0] as ConditionalMechanicOutcomeDraft['kind'])}/>
    - 4 ? 4 : scopeOptions.length} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{scope:values[0] as MechanicOutcomeScope})}/> + 4 ? 4 : scopeOptions.length} onchange={(values) => setOutcomeScope(clauseIndex,outcomeIndex,values[0] as MechanicOutcomeScope)}/> {#if outcome.scope === 'allies' || outcome.scope === 'self_and_allies' || outcome.scope === 'target'}
    updateOutcome(clauseIndex,outcomeIndex,{ - targeting: values[0] === 'none' - ? undefined - : {kind:'within_radius',population:values[0] as 'allies'|'enemies',radiusMeters:outcome.targeting?.radiusMeters ?? 20} - })} + options={targetingOptions(clause,outcome)} + value={mechanicTargetingMode(outcome.targeting)} + onchange={(values) => setOutcomeTargeting(clauseIndex,outcomeIndex,values[0] as MechanicTargetingMode)} /> {#if outcome.targeting} + {#if outcome.targeting.kind === 'event_target_area'} +

    EVENT TARGET CENTERDamages each valid enemy at or inside the radius once. Runtime events must supply the authoritative target sample.

    + {/if} {/if}
    {/if} @@ -1154,8 +1300,9 @@ {/each}
    +
    @@ -1198,6 +1345,8 @@ .branch>header>div{display:flex;align-items:end;gap:9px}.branch>header>div>span{align-self:center;color:var(--shd-orange);font:900 var(--shd-type-telemetry)/1 var(--shd-font-mono)}.branch>header .field{min-width:min(360px,55vw);margin:0} .branch-grid{display:grid;grid-template-columns:repeat(2,minmax(240px,1fr)) minmax(160px,.45fr);align-items:end;gap:var(--shd-gap-sm);padding:var(--shd-gap-sm)} .condition-controls{display:grid;grid-template-columns:minmax(260px,.7fr) minmax(320px,1.3fr);align-items:end;gap:var(--shd-gap-sm);padding:0 var(--shd-gap-sm) var(--shd-gap-sm)} + .ability-proc-controls{display:grid;grid-template-columns:minmax(240px,.7fr) minmax(180px,.4fr);align-items:end;gap:var(--shd-gap-sm)} + .ability-proc-controls .field small,.proc-chance-field small,.probability-field small,.ability-mutation-row .field small{display:block;margin-top:4px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.3} .health-boundaries{display:grid;grid-template-columns:repeat(2,minmax(150px,1fr));gap:var(--shd-gap-sm)} .health-boundaries .field small{display:block;margin-top:4px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.3} .predicate-fields{display:grid;grid-template-columns:minmax(170px,1fr) minmax(190px,.8fr);align-items:end;gap:var(--shd-gap-sm)} @@ -1207,12 +1356,15 @@ .branch-readout{display:grid;align-content:center;gap:3px;min-height:44px;padding:7px 9px;border:1px solid var(--shd-border);background:var(--shd-surface-raised)}.branch-readout span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.branch-readout strong{font-size:var(--shd-type-supporting)} .reducer-controls{display:grid;grid-template-columns:minmax(0,1fr) repeat(2,minmax(130px,.25fr));align-items:end;gap:var(--shd-gap-sm);padding:0 var(--shd-gap-sm) var(--shd-gap-sm)} .mutation-panel{margin:0 var(--shd-gap-sm) var(--shd-gap-sm);border:1px solid var(--shd-border-soft)} + .mutation-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:6px} .mutation-row{display:grid;grid-template-columns:minmax(260px,1fr) minmax(220px,1fr) minmax(110px,.35fr) minmax(240px,1fr) auto;align-items:end;gap:8px;padding:8px;border-bottom:1px solid var(--shd-border-soft)} + .ability-mutation-row{grid-template-columns:minmax(240px,1fr) minmax(180px,.5fr) auto}.cooldown-mutation-row{grid-template-columns:minmax(210px,.8fr) minmax(220px,1fr) minmax(220px,1fr) auto}.probability-field{max-width:260px} .outcome-card{display:grid;gap:8px;margin:var(--shd-gap-sm);padding:9px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-info);background:var(--shd-surface-raised)} .outcome-grid{display:grid;grid-template-columns:minmax(200px,1fr) minmax(360px,1.4fr) minmax(120px,.3fr);align-items:end;gap:8px} .variant-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:8px} .state-scaling{display:grid;grid-template-columns:minmax(0,1fr) minmax(260px,.75fr) repeat(2,minmax(110px,.3fr));align-items:end;gap:8px} .delivery-controls{display:grid;grid-template-columns:minmax(260px,1fr) minmax(150px,.35fr);align-items:end;gap:8px}.delivery-controls>.field{margin:0} + .area-targeting-note{grid-column:1/-1;display:grid;gap:2px;margin:0;padding:7px 9px;border-left:2px solid var(--shd-info);background:var(--shd-model-surface);color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}.area-targeting-note strong{color:var(--shd-info);font-size:var(--shd-type-telemetry);letter-spacing:.7px} .outcome-footer{display:flex;align-items:end;justify-content:space-between;gap:8px} .remove,.danger-quiet{min-height:38px;padding:0 10px;border:1px solid var(--shd-danger);background:transparent;color:var(--shd-danger);font-size:var(--shd-type-telemetry);font-weight:900;cursor:pointer} .remove:hover,.remove:focus-visible,.danger-quiet:hover,.danger-quiet:focus-visible{background:var(--shd-danger);color:var(--shd-surface)} diff --git a/src/lib/database/items.ts b/src/lib/database/items.ts index 71517eb..147ea86 100644 --- a/src/lib/database/items.ts +++ b/src/lib/database/items.ts @@ -3,6 +3,11 @@ import type { GameDataTargetType } from '$lib/game-data-targets'; export type DatabaseBadge = { label: string; tone?: string }; export type DatabaseMeter = { label: string; value: number }; +export type DatabaseWeaponModSlot = { + slot: string; + unlockedAtTierOne: boolean; + iconUrl?: string; +}; export type DatabaseFact = { label: string; value: string; @@ -26,8 +31,10 @@ export type DatabaseItem = { renderUrl?: string; gameIcon?: GameIconName; headerBadge?: string; + headerBadgeTone?: string; headerBadges?: string[]; headerMetaIcon?: GameIconName; + topBorderTone?: string; badges?: DatabaseBadge[]; facts: DatabaseFact[]; factLayout?: 'grid' | 'stack'; @@ -35,9 +42,17 @@ export type DatabaseItem = { lines?: DatabaseFact[]; afterLines?: DatabaseFact[]; iconGroups?: DatabaseIconGroup[]; + weaponModSlots?: DatabaseWeaponModSlot[]; description?: string; secondaryDescription?: string; descriptionStyle?: 'prose' | 'data'; + protocolPreview?: { + primaryStat: string; + primaryUnit: string; + primaryBaseValue: number; + talentDescription?: string; + talentEffects: Array<{ baseValue: number; unit: string }>; + }; }; export type DatabaseRelatedItem = { diff --git a/src/lib/mods/secondary-pool.test.ts b/src/lib/mods/secondary-pool.test.ts new file mode 100644 index 0000000..f530645 --- /dev/null +++ b/src/lib/mods/secondary-pool.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest'; +import { + MOD_SECONDARY_CAPACITY, + normalizeModSecondaryPool, +} from './secondary-pool'; + +const endpoints = ( + level1Min: number | string, + level1Max: number | string, + level20Min: number | string, + level20Max: number | string, +) => ({ level1Min, level1Max, level20Min, level20Max }); + +const range = (statId: string) => ({ + statId, + values: { + Specialized: endpoints('1.5', '2.5', '3.5', '4.5'), + Superior: endpoints(2, 3, 4, 5), + 'High-end': endpoints(3, 4, 5, 6), + }, +}); + +describe('shared Mod secondary pool', () => { + it('defines secondary capacity for every Mod quality', () => { + expect(MOD_SECONDARY_CAPACITY).toEqual({ + Standard: 0, + Specialized: 1, + Superior: 2, + 'High-end': 2, + }); + }); + + it('normalizes Attribute IDs and valid Level 1/20 endpoints', () => { + const result = normalizeModSecondaryPool([ + range(' critical-hit-chance '), + range('critical-hit-damage'), + ]); + + expect(result.error).toBeNull(); + expect(result.pool).toEqual([ + { + statId: 'critical-hit-chance', + values: { + Specialized: endpoints(1.5, 2.5, 3.5, 4.5), + Superior: endpoints(2, 3, 4, 5), + 'High-end': endpoints(3, 4, 5, 6), + }, + }, + { + statId: 'critical-hit-damage', + values: { + Specialized: endpoints(1.5, 2.5, 3.5, 4.5), + Superior: endpoints(2, 3, 4, 5), + 'High-end': endpoints(3, 4, 5, 6), + }, + }, + ]); + }); + + it('rejects duplicate Attributes after ID normalization', () => { + const result = normalizeModSecondaryPool([ + range('critical-hit-chance'), + range(' critical-hit-chance '), + ]); + + expect(result).toEqual({ + pool: [], + error: 'Choose a unique Attribute for every shared pool option.', + }); + }); + + it.each([ + [ + 'missing quality ranges', + { + ...range('critical-hit-chance'), + values: { + Specialized: endpoints(1, 2, 3, 4), + Superior: endpoints(2, 3, 4, 5), + }, + }, + ], + [ + 'reversed ranges', + { + ...range('critical-hit-chance'), + values: { + ...range('critical-hit-chance').values, + Specialized: endpoints(2, 1, 3, 4), + }, + }, + ], + [ + 'Level 20 regression', + { + ...range('critical-hit-chance'), + values: { + ...range('critical-hit-chance').values, + Specialized: endpoints(2, 3, 1, 2), + }, + }, + ], + ])('rejects %s', (_label, invalidRange) => { + const result = normalizeModSecondaryPool([ + invalidRange, + range('critical-hit-damage'), + ]); + + expect(result).toEqual({ + pool: [], + error: + 'Configure valid Level 1 and Level 20 ranges for Specialized, Superior, and High-end quality.', + }); + }); +}); diff --git a/src/lib/mods/secondary-pool.ts b/src/lib/mods/secondary-pool.ts new file mode 100644 index 0000000..5698e53 --- /dev/null +++ b/src/lib/mods/secondary-pool.ts @@ -0,0 +1,102 @@ +export const MOD_QUALITIES = [ + 'Standard', + 'Specialized', + 'Superior', + 'High-end', +] as const; + +export const MOD_SECONDARY_QUALITIES = [ + 'Specialized', + 'Superior', + 'High-end', +] as const; + +export const MOD_SECONDARY_CAPACITY = { + Standard: 0, + Specialized: 1, + Superior: 2, + 'High-end': 2, +} as const satisfies Record<(typeof MOD_QUALITIES)[number], number>; + +export type ModSecondaryQuality = (typeof MOD_SECONDARY_QUALITIES)[number]; + +export type ModSecondaryRange = { + statId: string; + values: Record< + ModSecondaryQuality, + { + level1Min: number; + level1Max: number; + level20Min: number; + level20Max: number; + } + >; +}; + +export function normalizeModSecondaryPool(input: unknown): { + pool: ModSecondaryRange[]; + error: string | null; +} { + if (!Array.isArray(input) || input.length < 2 || input.length > 50) { + return { + pool: [], + error: 'Configure between two and 50 shared secondary Attributes.', + }; + } + + const pool = input.map((candidate) => { + const range = candidate as Partial; + return { + statId: typeof range.statId === 'string' ? range.statId.trim() : '', + values: Object.fromEntries( + MOD_SECONDARY_QUALITIES.map((quality) => { + const values = range.values?.[quality] as + | Partial + | undefined; + return [ + quality, + { + level1Min: Number(values?.level1Min), + level1Max: Number(values?.level1Max), + level20Min: Number(values?.level20Min), + level20Max: Number(values?.level20Max), + }, + ]; + }), + ) as ModSecondaryRange['values'], + }; + }); + + if ( + pool.some((range) => !range.statId) || + new Set(pool.map((range) => range.statId)).size !== pool.length + ) { + return { + pool: [], + error: 'Choose a unique Attribute for every shared pool option.', + }; + } + + if ( + pool.some((range) => + MOD_SECONDARY_QUALITIES.some((quality) => { + const values = range.values[quality]; + return ( + Object.values(values).some((value) => !Number.isFinite(value)) || + values.level1Max < values.level1Min || + values.level20Max < values.level20Min || + values.level20Min < values.level1Min || + values.level20Max < values.level1Max + ); + }), + ) + ) { + return { + pool: [], + error: + 'Configure valid Level 1 and Level 20 ranges for Specialized, Superior, and High-end quality.', + }; + } + + return { pool, error: null }; +} diff --git a/src/lib/server/ai/mechanic-authoring-prompt.test.ts b/src/lib/server/ai/mechanic-authoring-prompt.test.ts index da91f2e..5bdc7e3 100644 --- a/src/lib/server/ai/mechanic-authoring-prompt.test.ts +++ b/src/lib/server/ai/mechanic-authoring-prompt.test.ts @@ -94,7 +94,7 @@ describe('AI mechanic authoring prompt', () => { }), ); - expect(AI_MECHANIC_PROMPT_VERSION).toBe(7); + expect(AI_MECHANIC_PROMPT_VERSION).toBe(10); expect(input.canonicalCatalog.grammar.outcome.optional).toEqual( expect.arrayContaining(['delivery', 'durationSeconds']), ); @@ -136,7 +136,7 @@ describe('AI mechanic authoring prompt', () => { ]), ); expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain( - 'Prompt contract version: 7', + 'Prompt contract version: 10', ); const advertised = structuredClone( @@ -185,7 +185,7 @@ describe('AI mechanic authoring prompt', () => { expect(parsed.ok).toBe(true); }); - it('advertises the canonical Extra Health change event contract in version 7', () => { + it('advertises the canonical Extra Health change event contract in version 10', () => { const input = JSON.parse( buildAiMechanicAuthoringInput({ request, @@ -196,9 +196,9 @@ describe('AI mechanic authoring prompt', () => { }), ); - expect(AI_MECHANIC_PROMPT_VERSION).toBe(7); + expect(AI_MECHANIC_PROMPT_VERSION).toBe(10); expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain( - 'Prompt contract version: 7', + 'Prompt contract version: 10', ); expect(input.canonicalCatalog.events).toContainEqual({ key: 'extra_health_changed', @@ -222,6 +222,144 @@ describe('AI mechanic authoring prompt', () => { }); }); + it('advertises assigned-ability activity for kill and assist predicates', () => { + const input = JSON.parse( + buildAiMechanicAuthoringInput({ + request, + attributes: [], + events: [ + MECHANIC_EVENT_REGISTRY.enemy_killed, + MECHANIC_EVENT_REGISTRY.assist, + ], + actions: [], + recipeKeys: [], + }), + ); + + for (const event of input.canonicalCatalog.events) { + expect(event.fields).toEqual( + expect.arrayContaining([ + { + path: 'values.assignedAbilityActive', + label: 'Assigned ability active', + type: 'boolean', + }, + ]), + ); + } + expect(input.examplesAndRules).toEqual( + expect.arrayContaining([ + expect.stringContaining('values.assignedAbilityActive'), + ]), + ); + }); + + it('advertises and round-trips aimed event-target area damage', () => { + const protocolRequest: AiMechanicAuthoringRequest = { + ...request, + context: { + sourceKind: 'os_protocol_talent', + sourceName: 'Untitled mechanic', + sourceDescription: + 'Critical Hits performed during aiming create an explosion with a radius of 5 meters around the target that deals damage equal to 2000% Firepower to them.', + }, + valueColumns: [{ key: 'base', variantKey: 'base', label: 'BASE' }], + allowProtocolUpgradeScaling: true, + }; + const input = JSON.parse( + buildAiMechanicAuthoringInput({ + request: protocolRequest, + attributes: [ + { + id: 'firepower-id', + name: 'Firepower', + description: null, + unit: '', + type: null, + category: null, + calculationType: null, + }, + ], + events: [MECHANIC_EVENT_REGISTRY.weapon_hit], + actions: [], + recipeKeys: [], + }), + ); + + expect(input.canonicalCatalog.events[0].fields).toContainEqual({ + path: 'values.aiming', + label: 'Aiming down sights', + type: 'boolean', + }); + expect( + input.canonicalCatalog.grammar.outcome.targeting.eventTargetArea, + ).toMatchObject({ + kind: 'event_target_area', + center: 'event_target', + validTargets: 'enemies', + }); + expect(input.examplesAndRules).toEqual( + expect.arrayContaining([ + expect.stringContaining('values.aiming'), + expect.stringContaining('event_target_area'), + ]), + ); + + const advertised = structuredClone( + input.canonicalCatalog.grammar.aimedCriticalAreaDamageShape, + ); + const outcome = advertised.clauses[0].outcomes[0]; + const base = JSON.parse( + JSON.stringify( + outcome.variants['replace-with-each-required-variant-key'], + ).replaceAll( + 'replace-with-an-exact-catalog-attribute-id', + 'firepower-id', + ), + ); + outcome.variants = { base }; + const parsed = parseConditionalMechanicDraft(advertised, { + variantKeys: ['base'], + allowedAttributeIds: new Set(['firepower-id']), + }); + expect(parsed.ok).toBe(true); + }); + + it('advertises reproducible outcome and clause proc probabilities in version 10', () => { + const input = JSON.parse( + buildAiMechanicAuthoringInput({ + request, + attributes: [], + events: [MECHANIC_EVENT_REGISTRY.skill_hit], + actions: [], + recipeKeys: [], + }), + ); + + expect(AI_MECHANIC_PROMPT_VERSION).toBe(10); + expect(input.canonicalCatalog.grammar.clause.optional).toEqual( + expect.arrayContaining([ + 'procChancePercent', + 'requiresAssignedAbilityActive', + ]), + ); + expect(input.canonicalCatalog.grammar.mutationKinds).toEqual( + expect.arrayContaining(['extend_duration', 'start_cooldown_on_max']), + ); + expect( + input.canonicalCatalog.grammar.probabilityOutcomeShape, + ).toMatchObject({ + probabilityPercent: 29, + resource: 'ammo', + }); + expect(input.examplesAndRules).toEqual( + expect.arrayContaining([ + expect.stringContaining('procChancePercent'), + expect.stringContaining('reproducible seeded random stream'), + ]), + ); + }); + it('advertises authoritative current Health percentage with strict band semantics', () => { const input = JSON.parse( buildAiMechanicAuthoringInput({ diff --git a/src/lib/server/ai/mechanic-authoring-prompt.ts b/src/lib/server/ai/mechanic-authoring-prompt.ts index 2099a46..c6b3bfb 100644 --- a/src/lib/server/ai/mechanic-authoring-prompt.ts +++ b/src/lib/server/ai/mechanic-authoring-prompt.ts @@ -3,7 +3,7 @@ import { CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES } from '$lib/calculations/mec import { MECHANIC_COMBAT_REFERENCE_REGISTRY } from '$lib/calculations/mechanic'; import type { AiMechanicAttribute } from './mechanic-authoring-context'; -export const AI_MECHANIC_PROMPT_VERSION = 7; +export const AI_MECHANIC_PROMPT_VERSION = 10; const mechanicsGrammar = { version: 1, @@ -40,7 +40,14 @@ const mechanicsGrammar = { }, clause: { required: ['key', 'event', 'if', 'reducer', 'mutations', 'outcomes'], - optional: ['label', 'recipeKey', 'cooldownSeconds', 'weight'], + optional: [ + 'label', + 'recipeKey', + 'cooldownSeconds', + 'weight', + 'procChancePercent', + 'requiresAssignedAbilityActive', + ], }, predicateKinds: ['true', 'exists', 'compare', 'all', 'any', 'not'], compareOperators: ['eq', 'ne', 'gt', 'gte', 'lt', 'lte'], @@ -63,6 +70,8 @@ const mechanicsGrammar = { 'derive', 'clear', 'transfer', + 'extend_duration', + 'start_cooldown_on_max', ], entitySelectors: ['agent', 'event_actor', 'event_target', 'event_field'], valueReferences: [ @@ -70,6 +79,7 @@ const mechanicsGrammar = { 'scenario', 'combat', 'equipment', + 'assigned_ability', 'attribute', 'reducer', 'pool', @@ -97,6 +107,7 @@ const mechanicsGrammar = { }, }, { ref: { source: 'equipment', path: 'protocolTalentUpgrades' } }, + { ref: { source: 'assigned_ability', path: 'remainingDurationSeconds' } }, { ref: { source: 'attribute', @@ -200,6 +211,16 @@ const mechanicsGrammar = { value: 'all', clearSource: true, }, + { + kind: 'extend_duration', + target: 'assigned_ability', + seconds: 0.3, + }, + { + kind: 'start_cooldown_on_max', + stackPool: 'extension-stacks', + cooldownPool: 'extension-cooldown', + }, ], outcome: CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES, continuousRestorationOutcomeShape: { @@ -232,6 +253,83 @@ const mechanicsGrammar = { }, }, }, + probabilityOutcomeShape: { + key: 'recover-ammo', + label: 'Recover Ammo', + phase: 'after_event', + kind: 'restoration', + scope: 'self', + basis: 'flat', + resource: 'ammo', + probabilityPercent: 29, + variants: { + 'replace-with-each-required-variant-key': { value: 1 }, + }, + }, + aimedCriticalAreaDamageShape: { + version: 1, + simulationReadiness: 'ready', + scenarioInputs: [], + pools: [], + clauses: [ + { + key: 'aimed-critical-explosion', + label: 'Aimed critical hit explosion', + event: 'weapon_hit', + if: { + kind: 'all', + predicates: [ + { kind: 'exists', value: { source: 'event', path: 'targetId' } }, + { + kind: 'compare', + left: { source: 'event', path: 'values.critical' }, + operator: 'eq', + right: true, + }, + { + kind: 'compare', + left: { source: 'event', path: 'values.aiming' }, + operator: 'eq', + right: true, + }, + ], + }, + reducer: { kind: 'none' }, + mutations: [], + outcomes: [ + { + key: 'critical-explosion-damage', + label: 'Critical explosion damage', + phase: 'after_event', + kind: 'direct_damage', + scope: 'target', + basis: 'flat', + targeting: { + kind: 'event_target_area', + center: 'event_target', + validTargets: 'enemies', + radiusMeters: 5, + }, + variants: { + 'replace-with-each-required-variant-key': { + value: 2000, + expression: { + op: 'multiply', + left: 20, + right: { + ref: { + source: 'attribute', + attributeId: 'replace-with-an-exact-catalog-attribute-id', + }, + }, + }, + }, + }, + }, + ], + }, + ], + }, preciseTraumaShape: { version: 1, simulationReadiness: 'ready', @@ -453,9 +551,16 @@ export function buildAiMechanicAuthoringInput(input: { 'A target-scoped pool owns an independent value and expiry timer for each entity. Bind hit-created target state with entity:{kind:"event_target"}; do not collapse different enemies into one Agent-scoped pool.', 'Writing a target-scoped pool refreshes only that target entity timer. An on_exit outcome runs when that entity expires and uses the entity previous state for stack bands and perStack multiplication, even though the stored pool value has already reset.', 'When an outcome value is exactly a live Attribute amount, use the Attribute reference directly as the variant expression. Divide by 100 only when a displayed percentage is being used as a multiplier.', + 'A weapon_hit may compare values.aiming with true only when the wording requires aiming down sights at hit resolution. Missing aiming evidence does not match; do not replace it with a scenario assumption.', + 'An explosion centered on the hit target uses target-scoped direct_damage with targeting kind event_target_area, center event_target, validTargets enemies, and the stated positive radiusMeters. The runtime applies one equal damage emission to each authoritative valid enemy at or inside the radius. Do not use legacy within_radius metadata for explosion damage.', 'Precise Trauma is representable as one target-scoped stack pool with a one-second expiry: body shots add one stack, headshots add two, and one on_exit direct-damage outcome uses the expiring target previous stack count. This models the stated one-second debuff without inventing generic repeated target ticks.', 'OS Protocol upgrade scaling may use equipment.protocolTalentUpgrades only when allowProtocolUpgradeScaling is true.', 'Assigned-ability scope may be used only when allowAssignedAbility is true.', + 'When allowAssignedAbility is true, a kill or assist that must occur while the source item\'s assigned ability is active uses enemy_killed or assist with {source:"event",path:"values.assignedAbilityActive"} compared to true. This field is optional on the event; a missing value does not match.', + 'When allowAssignedAbility is true, requiresAssignedAbilityActive gates a clause on the runtime assigned-ability state. skill_deployed and signature_ability_deployed may supply values.assignedAbilityDurationSeconds to begin that state. At an exact expiry timestamp, the ability expires before a later clause can extend it.', + 'procChancePercent is a clause-level independent percentage roll after its predicate and cooldown checks but before the reducer, mutations, and outcomes. Use it when a successful proc changes state. probabilityPercent is an outcome-level independent percentage roll for one outcome only.', + 'Custom simulation uses a reproducible seeded random stream. Maximum Potential resolves every nonzero chance as successful. Never use clause weight as a proc chance.', + 'extend_duration may target only assigned_ability and does nothing while the ability is inactive. start_cooldown_on_max starts a configured expiring Agent cooldown pool only when a mutation in the same clause transitions its capped stack pool to its maximum.', 'If cadence is not stated, ask rather than assuming a one-second interval.', 'If wording contains separate actions such as restoration on a periodic snapshot and a health cost on every shot, represent them as separate clauses.', 'A pool with expiresAfterSeconds refreshes the shared expiry for every current stack whenever any mutation writes that pool. Use it for mechanics where gaining a stack refreshes the duration of the full stack pool.', diff --git a/src/lib/server/ai/mechanic-authoring-rate-limit.test.ts b/src/lib/server/ai/mechanic-authoring-rate-limit.test.ts index 99901b1..ceaeda5 100644 --- a/src/lib/server/ai/mechanic-authoring-rate-limit.test.ts +++ b/src/lib/server/ai/mechanic-authoring-rate-limit.test.ts @@ -9,21 +9,35 @@ describe('AI mechanic authoring rate limit', () => { beforeEach(() => resetAiMechanicRateLimitForTests()); it('allows only one in-flight request per account', () => { - const release = reserveAiMechanicGeneration('agent-1', 1_000); - expect(() => reserveAiMechanicGeneration('agent-1', 1_001)).toThrow( + const release = reserveAiMechanicGeneration('agent-1', false, 1_000); + expect(() => reserveAiMechanicGeneration('agent-1', false, 1_001)).toThrow( AiMechanicRateLimitError, ); release(); - expect(() => reserveAiMechanicGeneration('agent-1', 1_002)).not.toThrow(); + expect(() => + reserveAiMechanicGeneration('agent-1', false, 1_002), + ).not.toThrow(); }); it('enforces the hourly account limit', () => { for (let request = 0; request < 10; request += 1) { - const release = reserveAiMechanicGeneration('agent-1', 10_000 + request); + const release = reserveAiMechanicGeneration( + 'agent-1', + false, + 10_000 + request, + ); release(); } - expect(() => reserveAiMechanicGeneration('agent-1', 10_100)).toThrow( + expect(() => reserveAiMechanicGeneration('agent-1', false, 10_100)).toThrow( 'This account has reached the hourly mechanic authoring limit.', ); }); + + it('does not limit administrators', () => { + const releases = Array.from({ length: 101 }, () => + reserveAiMechanicGeneration('admin-1', true, 10_000), + ); + + for (const release of releases) release(); + }); }); diff --git a/src/lib/server/ai/mechanic-authoring-rate-limit.ts b/src/lib/server/ai/mechanic-authoring-rate-limit.ts index 3a24b44..8ad74ec 100644 --- a/src/lib/server/ai/mechanic-authoring-rate-limit.ts +++ b/src/lib/server/ai/mechanic-authoring-rate-limit.ts @@ -27,7 +27,12 @@ const retryAfter = (requests: number[], now: number) => * duplicate submissions and basic abuse; OpenAI project budgets remain the hard * cross-instance spending boundary. */ -export function reserveAiMechanicGeneration(userId: string, now = Date.now()) { +export function reserveAiMechanicGeneration( + userId: string, + isAdmin = false, + now = Date.now(), +) { + if (isAdmin) return () => {}; if (inFlightUsers.has(userId)) { throw new AiMechanicRateLimitError( 'A mechanic authoring request is already running for this account.', diff --git a/src/lib/server/audit.ts b/src/lib/server/audit.ts index 56af72d..24fec61 100644 --- a/src/lib/server/audit.ts +++ b/src/lib/server/audit.ts @@ -34,6 +34,7 @@ export async function recordIsacAudit(input: { export async function auditRecord( table: string, id: string, + suppliedDatabase?: Database, ): Promise | null> { if ( ![ @@ -56,7 +57,7 @@ export async function auditRecord( ) { throw new Error('Unsupported audit table'); } - const database = await getDatabase(); + const database = suppliedDatabase ?? (await getDatabase()); return ( (await ( await database.prepare(`SELECT * FROM ${table} WHERE id=?`) diff --git a/src/lib/server/conditional-effects.test.ts b/src/lib/server/conditional-effects.test.ts index 386d21d..ee418ec 100644 --- a/src/lib/server/conditional-effects.test.ts +++ b/src/lib/server/conditional-effects.test.ts @@ -187,6 +187,7 @@ describe('conditional mechanic persistence adapter', () => { value_scaling TEXT, unit TEXT, damage_basis TEXT, application_phase TEXT, combat_timing TEXT, resource_kind TEXT, delivery_mode TEXT, delivery_duration_seconds REAL, targeting_json TEXT, min_state_value REAL, max_state_value REAL, + probability_percent REAL, description_slot INTEGER, sort_order INTEGER, created_at TEXT, updated_at TEXT )`); @@ -220,6 +221,8 @@ describe('conditional mechanic persistence adapter', () => { key: 'weapon-kill', label: 'Weapon kill', event: 'enemy_killed', + procChancePercent: 29, + requiresAssignedAbilityActive: true, if: { kind: 'compare', left: { @@ -255,6 +258,7 @@ describe('conditional mechanic persistence adapter', () => { population: 'allies', radiusMeters: 40, }, + probabilityPercent: 29, variants: { I: { value: 240 }, II: { value: 336 }, @@ -263,12 +267,18 @@ describe('conditional mechanic persistence adapter', () => { { key: 'target-detonation', label: 'Target Detonation', - phase: 'on_exit', + phase: 'after_event', kind: 'direct_damage', scope: 'target', basis: 'flat', statePool: 'marked-target', perStack: true, + targeting: { + kind: 'event_target_area', + center: 'event_target', + validTargets: 'enemies', + radiusMeters: 5, + }, variants: { I: { value: 240, @@ -322,6 +332,23 @@ describe('conditional mechanic persistence adapter', () => { mutations: [], outcomes: [], }, + { + key: 'tactical-link-assist', + label: 'Tactical Link assist', + event: 'assist', + if: { + kind: 'compare', + left: { + source: 'event', + path: 'values.assignedAbilityActive', + }, + operator: 'eq', + right: true, + }, + reducer: { kind: 'none' }, + mutations: [], + outcomes: [], + }, ], }, ); @@ -366,11 +393,23 @@ describe('conditional mechanic persistence adapter', () => { population: 'allies', radiusMeters: 40, }, + probabilityPercent: 29, variants: { I: { value: 240 }, II: { value: 336 }, }, }); + expect(loaded?.clauses[0].outcomes[1]).toMatchObject({ + phase: 'after_event', + kind: 'direct_damage', + scope: 'target', + targeting: { + kind: 'event_target_area', + center: 'event_target', + validTargets: 'enemies', + radiusMeters: 5, + }, + }); expect(loaded?.clauses[0].if).toEqual({ kind: 'compare', left: { @@ -381,6 +420,10 @@ describe('conditional mechanic persistence adapter', () => { operator: 'lt', right: 75, }); + expect(loaded?.clauses[0]).toMatchObject({ + procChancePercent: 29, + requiresAssignedAbilityActive: true, + }); expect(loaded?.pools[0]).toMatchObject({ key: 'marked-target', stateKind: 'target_mark', @@ -399,7 +442,7 @@ describe('conditional mechanic persistence adapter', () => { }, ]); expect(loaded?.clauses[0].outcomes[1]).toMatchObject({ - phase: 'on_exit', + phase: 'after_event', kind: 'direct_damage', scope: 'target', statePool: 'marked-target', @@ -430,5 +473,14 @@ describe('conditional mechanic persistence adapter', () => { right: true, }, }); + expect(loaded?.clauses[2]).toMatchObject({ + event: 'assist', + if: { + kind: 'compare', + left: { source: 'event', path: 'values.assignedAbilityActive' }, + operator: 'eq', + right: true, + }, + }); }); }); diff --git a/src/lib/server/conditional-effects.ts b/src/lib/server/conditional-effects.ts index 96a236e..a2eef1b 100644 --- a/src/lib/server/conditional-effects.ts +++ b/src/lib/server/conditional-effects.ts @@ -90,6 +90,7 @@ const outcomeMetadataColumns = [ 'delivery_mode', 'delivery_duration_seconds', 'targeting_json', + 'probability_percent', 'state_pool_id', 'min_state_value', 'max_state_value', @@ -205,6 +206,10 @@ export function conditionalRuleDraftFromRows( .filter((row) => row.rule_id === rule.id && Number(row.active ?? 1) === 1) .sort((left, right) => Number(left.sort_order) - Number(right.sort_order)) .map((row): ConditionalMechanicClauseDraft => { + const parameters = parseJson>( + row.recipe_parameters_json, + {}, + ); const grouped = new Map(); for (const outcome of rowsByClause.get(String(row.id)) ?? []) { const key = String(outcome.effect_key); @@ -268,6 +273,10 @@ export function conditionalRuleDraftFromRows( ...(first.targeting_json ? { targeting: parseJson(first.targeting_json, undefined) } : {}), + ...(first.probability_percent === null || + first.probability_percent === undefined + ? {} + : { probabilityPercent: Number(first.probability_percent) }), ...(first.stat_attribute_id ? { attributeId: String(first.stat_attribute_id) } : {}), @@ -329,6 +338,15 @@ export function conditionalRuleDraftFromRows( ), } : {}), + ...(typeof parameters.procChancePercent === 'number' + ? { procChancePercent: parameters.procChancePercent } + : {}), + ...(typeof parameters.requiresAssignedAbilityActive === 'boolean' + ? { + requiresAssignedAbilityActive: + parameters.requiresAssignedAbilityActive, + } + : {}), ...(row.cooldown_seconds === null || row.cooldown_seconds === undefined ? {} : { cooldownSeconds: Number(row.cooldown_seconds) }), @@ -495,7 +513,17 @@ export async function saveConditionalRuleInTransaction( clause.label || clause.key, clause.event, clause.recipeKey ?? null, - '{}', + JSON.stringify({ + ...(clause.procChancePercent === undefined + ? {} + : { procChancePercent: clause.procChancePercent }), + ...(clause.requiresAssignedAbilityActive === undefined + ? {} + : { + requiresAssignedAbilityActive: + clause.requiresAssignedAbilityActive, + }), + }), JSON.stringify(clause.if ?? { kind: 'true' }), JSON.stringify(clause.reducer), clause.weight === undefined ? null : JSON.stringify(clause.weight), @@ -518,9 +546,9 @@ export async function saveConditionalRuleInTransaction( application_kind,effect_scope,stat_attribute_id,value,value_expression_json, value_basis,value_scaling,unit,damage_basis,application_phase,combat_timing, resource_kind,delivery_mode,delivery_duration_seconds, - targeting_json,min_state_value,max_state_value,description_slot,sort_order, + targeting_json,probability_percent,min_state_value,max_state_value,description_slot,sort_order, created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`) ).run( randomUUID(), ruleId, @@ -552,6 +580,7 @@ export async function saveConditionalRuleInTransaction( outcome.targeting === undefined ? null : JSON.stringify(outcome.targeting), + outcome.probabilityPercent ?? null, outcome.minStacks ?? null, outcome.maxStacks ?? null, outcome.descriptionSlot ?? null, diff --git a/src/lib/server/data-moderation.ts b/src/lib/server/data-moderation.ts index f42b9ba..b43f20f 100644 --- a/src/lib/server/data-moderation.ts +++ b/src/lib/server/data-moderation.ts @@ -37,14 +37,31 @@ const proposalResultKeys: Partial< saveAugmentProfile: { saved: 'augmentProfileSaved' }, }, weapons: { save: { saved: 'weaponSaved', mode: 'weaponMode' } }, - weaponmods: { save: { saved: 'weaponModSaved', mode: 'weaponModMode' } }, - gearmods: { save: { saved: 'gearModSaved', mode: 'gearModMode' } }, + weaponmods: { + save: { saved: 'weaponModSaved', mode: 'weaponModMode' }, + saveSlotIcon: { saved: 'weaponModSlotIconSaved' }, + saveSecondaryPool: { + saved: 'weaponModSecondaryPoolSaved', + mode: 'weaponModSecondaryPoolMode', + }, + }, + gearmods: { + save: { saved: 'gearModSaved', mode: 'gearModMode' }, + saveSecondaryPool: { + saved: 'gearModSecondaryPoolSaved', + mode: 'gearModSecondaryPoolMode', + }, + }, talents: { saveTalent: { saved: 'talentSaved', mode: 'talentMode' } }, protocols: { save: { saved: 'protocolSaved', mode: 'protocolMode' }, saveProfile: { saved: 'profileSaved' }, }, - skillchips: { save: { saved: 'skillChipSaved', mode: 'skillChipMode' } }, + skillchips: { + save: { saved: 'skillChipSaved', mode: 'skillChipMode' }, + savePrimaryProfile: { saved: 'skillChipPrimaryProfileSaved' }, + saveSecondaryPool: { saved: 'skillChipSecondaryPoolSaved' }, + }, specializations: { saveProgression: { saved: 'progressionSaved' }, saveSpecialization: { diff --git a/src/lib/server/game-data-icons.ts b/src/lib/server/game-data-icons.ts index 5432112..6e4d9c4 100644 --- a/src/lib/server/game-data-icons.ts +++ b/src/lib/server/game-data-icons.ts @@ -7,6 +7,7 @@ import { BackgroundRemovalError, removeBackgroundPreview, } from './remove-background'; +import { weaponModSlotDefinitionById } from '$lib/weapons/mod-slots'; export const GAME_ICON_TYPES = [ 'talent', @@ -16,6 +17,7 @@ export const GAME_ICON_TYPES = [ 'specialization', 'specialization_ability', 'specialization_node', + 'weapon_mod_slot', ] as const; export type GameDataIconType = (typeof GAME_ICON_TYPES)[number]; export const MAX_GAME_ICON_BYTES = 2 * 1024 * 1024; @@ -23,7 +25,7 @@ export const MAX_GAME_ICON_PIXELS = 12_000_000; export const GAME_ICON_SIZE = 256; const ENTITY_ID_PATTERN = /^[0-9a-f-]{36}$/i; const STORAGE_KEY_PATTERN = - /^(talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i; + /^(talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node|weapon_mod_slot)-[0-9a-f-]{36}\.webp$/i; const LIBRARY_CATEGORY: Record = { talent: 'Talents', brand_set: 'Brands', @@ -32,6 +34,7 @@ const LIBRARY_CATEGORY: Record = { specialization: 'Specializations', specialization_ability: 'Specializations', specialization_node: 'Specializations', + weapon_mod_slot: 'Weapon Mods', }; const LIBRARY_LABEL: Record = { talent: 'Talent', @@ -41,6 +44,7 @@ const LIBRARY_LABEL: Record = { specialization: 'Specialization', specialization_ability: 'Specialization Ability', specialization_node: 'Specialization Tree Node', + weapon_mod_slot: 'Weapon Mod Slot', }; export class GameDataIconValidationError extends Error {} @@ -255,6 +259,13 @@ async function libraryMetadata( ], }; } + case 'weapon_mod_slot': { + const definition = weaponModSlotDefinitionById(entityId); + return { + name: definition ? `${definition.name} Slot` : null, + tags: ['weapon mod', 'slot', definition?.name], + }; + } } return { name: null, tags: [] }; } diff --git a/src/lib/server/managed-images.test.ts b/src/lib/server/managed-images.test.ts index 2edd9bd..a68829e 100644 --- a/src/lib/server/managed-images.test.ts +++ b/src/lib/server/managed-images.test.ts @@ -1,17 +1,24 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import sharp from 'sharp'; -const { rmMock } = vi.hoisted(() => ({ rmMock: vi.fn() })); +const { mkdirMock, rmMock, writeFileMock } = vi.hoisted(() => ({ + mkdirMock: vi.fn(), + rmMock: vi.fn(), + writeFileMock: vi.fn(), +})); vi.mock('node:fs/promises', async (importOriginal) => ({ ...(await importOriginal()), + mkdir: mkdirMock, rm: rmMock, + writeFile: writeFileMock, })); import { GAME_ICON_TYPES } from './game-data-icons'; import { GAME_DATA_RENDER_TYPES, ManagedImageDeletionError, + applyGameDataRender, deleteManagedImage, processManagedImage, } from './managed-images'; @@ -26,6 +33,16 @@ class FakeDatabase { prepare(sql: string) { return Promise.resolve({ get: async (...args: unknown[]) => { + if ( + sql.startsWith('SELECT * FROM managed_images') && + sql.includes('source_entity_type') + ) { + return [...this.images.values()].find( + (row) => + row.source_entity_type === args[0] && + row.source_entity_id === args[1], + ); + } if (sql.startsWith('SELECT * FROM managed_images')) return this.images.get(String(args[0])); if (sql.startsWith('SELECT * FROM game_data_icons')) { @@ -42,7 +59,64 @@ class FakeDatabase { } if (sql.startsWith('DELETE FROM managed_images')) { const row = this.images.get(String(args[0])); - if (row?.storage_key === args[1]) this.images.delete(String(args[0])); + if (!sql.includes('storage_key') || row?.storage_key === args[1]) + this.images.delete(String(args[0])); + return; + } + if (sql.startsWith('UPDATE managed_images SET')) { + const row = this.images.get(String(args[7])); + if (!row) throw new Error('Missing managed image for update'); + this.images.set(String(args[7]), { + ...row, + category: args[0], + tags_json: args[1], + is_icon: 0, + force_white: 0, + original_filename: args[2], + byte_size: args[3], + width: args[4], + height: args[5], + updated_at: args[6], + }); + return; + } + if (sql.startsWith('INSERT INTO managed_images')) { + const [ + id, + name, + category, + tagsJson, + isIcon, + forceWhite, + storageKey, + originalFilename, + byteSize, + width, + height, + sourceType, + sourceEntityId, + createdBy, + createdAt, + updatedAt, + ] = args; + this.images.set(String(id), { + id, + name, + category, + tags_json: tagsJson, + is_icon: isIcon, + force_white: forceWhite, + storage_key: storageKey, + original_filename: originalFilename, + byte_size: byteSize, + width, + height, + source_entity_type: sourceType, + source_entity_id: sourceEntityId, + created_by: createdBy, + created_at: createdAt, + updated_at: updatedAt, + }); return; } throw new Error(`Unexpected run: ${sql}`); @@ -210,3 +284,94 @@ describe('processManagedImage', () => { expect(metadata.height).toBe(1000); }); }); + +describe('applyGameDataRender', () => { + beforeEach(() => { + mkdirMock.mockReset().mockResolvedValue(undefined); + rmMock.mockReset().mockResolvedValue(undefined); + writeFileMock.mockReset().mockResolvedValue(undefined); + }); + + it.each(GAME_DATA_RENDER_TYPES)( + 'persists and returns authoritative %s render metadata', + async (type) => { + const database = new FakeDatabase(); + const result = await applyGameDataRender( + database as never, + type, + sourceId, + 'Test Entity', + { + file: new File(['source'], 'source.png', { type: 'image/png' }), + remove: false, + processed: { + output: Buffer.from('processed-render'), + width: 1000, + height: 1000, + }, + }, + 'operator-1', + ); + + expect(result).toMatchObject({ + original_filename: 'source.png', + byte_size: Buffer.byteLength('processed-render'), + width: 1000, + height: 1000, + source_entity_type: `${type}_render`, + source_entity_id: sourceId, + created_by: 'operator-1', + created_at: expect.any(String), + updated_at: expect.any(String), + }); + expect(writeFileMock).toHaveBeenCalledOnce(); + }, + ); + + it('returns refreshed replacement metadata without rewriting creator provenance', async () => { + const database = new FakeDatabase(); + database.images.set(imageId, { + id: imageId, + name: 'Ability render', + category: 'Specializations', + tags_json: '[]', + is_icon: 0, + force_white: 0, + storage_key: `library-${imageId}.webp`, + original_filename: 'old.png', + byte_size: 3, + width: 100, + height: 100, + source_entity_type: 'specialization_ability_render', + source_entity_id: sourceId, + created_by: 'original-operator', + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:00:00.000Z', + }); + + const result = await applyGameDataRender( + database as never, + 'specialization_ability', + sourceId, + 'Ability', + { + file: new File(['new'], 'new.png', { type: 'image/png' }), + remove: false, + processed: { + output: Buffer.from('replacement'), + width: 1000, + height: 1000, + }, + }, + 'replacement-operator', + ); + + expect(result).toMatchObject({ + id: imageId, + original_filename: 'new.png', + byte_size: Buffer.byteLength('replacement'), + created_by: 'original-operator', + created_at: '2026-01-01T00:00:00.000Z', + }); + }); +}); diff --git a/src/lib/server/managed-images.ts b/src/lib/server/managed-images.ts index 3452ee9..5d870b0 100644 --- a/src/lib/server/managed-images.ts +++ b/src/lib/server/managed-images.ts @@ -41,7 +41,7 @@ export type GameDataRenderType = (typeof GAME_DATA_RENDER_TYPES)[number]; const MAX_PIXELS = 24_000_000; const ID_PATTERN = /^[0-9a-f-]{36}$/i; const KEY_PATTERN = - /^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i; + /^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node|weapon_mod_slot)-[0-9a-f-]{36}\.webp$/i; export class ManagedImageValidationError extends Error {} export class ManagedImageDeletionError extends Error {} @@ -250,7 +250,7 @@ export async function applyGameDataRender( ); await ( await database.prepare(`UPDATE managed_images SET category=?,tags_json=?,is_icon=0,force_white=0, - original_filename=?,byte_size=?,width=?,height=?,created_by=?,updated_at=? WHERE id=?`) + original_filename=?,byte_size=?,width=?,height=?,updated_at=? WHERE id=?`) ).run( category[type], tags, @@ -258,19 +258,14 @@ export async function applyGameDataRender( processed.output.length, processed.width, processed.height, - actorId, now, existing.id, ); - return { - ...existing, - category: category[type], - tags_json: tags, - byte_size: processed.output.length, - width: processed.width, - height: processed.height, - updated_at: now, - }; + return ( + (await ( + await database.prepare('SELECT * FROM managed_images WHERE id=?') + ).get(existing.id)) ?? null + ); } const id = randomUUID(); const storageKey = await saveManagedImageFile(id, processed.output); @@ -298,20 +293,11 @@ export async function applyGameDataRender( now, now, ); - return { - id, - name, - category: category[type], - tags_json: tags, - is_icon: 0, - storage_key: storageKey, - byte_size: processed.output.length, - width: processed.width, - height: processed.height, - source_entity_type: sourceType, - source_entity_id: entityId, - updated_at: now, - }; + return ( + (await ( + await database.prepare('SELECT * FROM managed_images WHERE id=?') + ).get(id)) ?? null + ); } export async function deleteManagedImage(database: Database, id: string) { diff --git a/src/lib/server/mod-secondary-pools.ts b/src/lib/server/mod-secondary-pools.ts new file mode 100644 index 0000000..a63ab8d --- /dev/null +++ b/src/lib/server/mod-secondary-pools.ts @@ -0,0 +1,100 @@ +import type { Database } from '@tursodatabase/database'; +import { + MOD_SECONDARY_QUALITIES, + normalizeModSecondaryPool, + type ModSecondaryRange, +} from '$lib/mods/secondary-pool'; + +export type ModSecondaryPoolKind = 'weapon' | 'gear'; + +function tables(kind: ModSecondaryPoolKind) { + return { + options: `${kind}_mod_secondary_options`, + values: `${kind}_mod_secondary_quality_values`, + }; +} + +export async function getModSecondaryPool( + database: Database, + kind: ModSecondaryPoolKind, +): Promise { + const table = tables(kind); + const [options, values] = await Promise.all([ + ( + await database.prepare(`SELECT stat_attribute_id FROM ${table.options} + ORDER BY stat_attribute_id`) + ).all(), + ( + await database.prepare(`SELECT * FROM ${table.values} + ORDER BY stat_attribute_id,quality`) + ).all(), + ]); + + const pool = options.map((option) => { + const optionValues = values.filter( + (value) => value.stat_attribute_id === option.stat_attribute_id, + ); + if ( + optionValues.length !== MOD_SECONDARY_QUALITIES.length || + MOD_SECONDARY_QUALITIES.some( + (quality) => !optionValues.some((value) => value.quality === quality), + ) + ) { + throw new Error( + `${kind} Mod secondary Attribute ${String(option.stat_attribute_id)} does not have a complete quality profile.`, + ); + } + return { + statId: String(option.stat_attribute_id), + values: Object.fromEntries( + optionValues.map((value) => [ + String(value.quality), + { + level1Min: Number(value.level_1_min), + level1Max: Number(value.level_1_max), + level20Min: Number(value.level_20_min), + level20Max: Number(value.level_20_max), + }, + ]), + ) as ModSecondaryRange['values'], + }; + }); + if (!pool.length) return []; + const normalized = normalizeModSecondaryPool(pool); + if (normalized.error) { + throw new Error(`${kind} Mod secondary pool: ${normalized.error}`); + } + return normalized.pool; +} + +export async function replaceModSecondaryPool( + database: Database, + kind: ModSecondaryPoolKind, + pool: ModSecondaryRange[], +) { + const table = tables(kind); + await database.exec(`DELETE FROM ${table.values}`); + await database.exec(`DELETE FROM ${table.options}`); + for (const range of pool) { + await ( + await database.prepare( + `INSERT INTO ${table.options}(stat_attribute_id) VALUES(?)`, + ) + ).run(range.statId); + for (const quality of MOD_SECONDARY_QUALITIES) { + const values = range.values[quality]; + await ( + await database.prepare(`INSERT INTO ${table.values} + (stat_attribute_id,quality,level_1_min,level_1_max,level_20_min,level_20_max) + VALUES(?,?,?,?,?,?)`) + ).run( + range.statId, + quality, + values.level1Min, + values.level1Max, + values.level20Min, + values.level20Max, + ); + } + } +} diff --git a/src/lib/server/os-protocol-profiles.test.ts b/src/lib/server/os-protocol-profiles.test.ts new file mode 100644 index 0000000..95342ab --- /dev/null +++ b/src/lib/server/os-protocol-profiles.test.ts @@ -0,0 +1,96 @@ +import { connect } from '@tursodatabase/database'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + getOsProtocolProfileAttributeIds, + type OsProtocolAttributeType, +} from './os-protocol-profiles'; + +const databases: Array>> = []; + +afterEach(async () => { + await Promise.all(databases.splice(0).map((database) => database.close())); +}); + +async function databaseWithAttributes() { + const database = await connect(':memory:'); + databases.push(database); + await database.exec(`CREATE TABLE stat_attributes ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + unit TEXT NOT NULL, + active INTEGER NOT NULL + )`); + for (const [id, name, unit] of [ + ['firepower-flat', 'Firepower', ''], + ['firepower-percent', 'Firepower', '%'], + ['toughness-flat', 'Toughness', ''], + ['toughness-percent', 'Toughness', '%'], + ['engineering-flat', 'Engineering', ''], + ['weapon-damage-flat', 'Weapon Damage', ''], + ['weapon-damage-percent', 'Weapon Damage', '%'], + ['skill-damage-percent', 'Skill Damage', '%'], + ['health-flat', 'Health', ''], + ]) { + await ( + await database.prepare( + 'INSERT INTO stat_attributes(id,name,unit,active) VALUES(?,?,?,1)', + ) + ).run(id, name, unit); + } + return database; +} + +describe('OS Protocol profile Attributes', () => { + it.each([ + [ + 'Firepower', + { + Core: 'firepower-flat', + Damage: 'weapon-damage-flat', + Health: 'health-flat', + }, + ], + [ + 'Toughness', + { + Core: 'toughness-flat', + Damage: 'weapon-damage-flat', + Health: 'health-flat', + }, + ], + [ + 'Engineering', + { + Core: 'engineering-flat', + Damage: 'skill-damage-percent', + Health: 'health-flat', + }, + ], + ] as const)('resolves the exact %s role rows', async (type, expected) => { + const database = await databaseWithAttributes(); + + await expect( + getOsProtocolProfileAttributeIds( + database, + type as OsProtocolAttributeType, + ), + ).resolves.toEqual(expected); + }); + + it('reports a missing required role without selecting a similarly named row', async () => { + const database = await databaseWithAttributes(); + await ( + await database.prepare( + "DELETE FROM stat_attributes WHERE id='weapon-damage-flat'", + ) + ).run(); + + await expect( + getOsProtocolProfileAttributeIds(database, 'Firepower'), + ).resolves.toEqual({ + Core: 'firepower-flat', + Damage: null, + Health: 'health-flat', + }); + }); +}); diff --git a/src/lib/server/os-protocol-profiles.ts b/src/lib/server/os-protocol-profiles.ts new file mode 100644 index 0000000..e47af03 --- /dev/null +++ b/src/lib/server/os-protocol-profiles.ts @@ -0,0 +1,48 @@ +import type { Database } from '@tursodatabase/database'; + +export const OS_PROTOCOL_PROFILE_ATTRIBUTE_SPECS = { + Firepower: { + Core: { name: 'Firepower', unit: '' }, + Damage: { name: 'Weapon Damage', unit: '' }, + Health: { name: 'Health', unit: '' }, + }, + Toughness: { + Core: { name: 'Toughness', unit: '' }, + Damage: { name: 'Weapon Damage', unit: '' }, + Health: { name: 'Health', unit: '' }, + }, + Engineering: { + Core: { name: 'Engineering', unit: '' }, + Damage: { name: 'Skill Damage', unit: '%' }, + Health: { name: 'Health', unit: '' }, + }, +} as const; + +export type OsProtocolAttributeType = + keyof typeof OS_PROTOCOL_PROFILE_ATTRIBUTE_SPECS; +export type OsProtocolSecondaryRole = 'Core' | 'Damage' | 'Health'; + +export async function getOsProtocolProfileAttributeIds( + database: Database, + attributeType: OsProtocolAttributeType, +) { + const specs = OS_PROTOCOL_PROFILE_ATTRIBUTE_SPECS[attributeType]; + const entries = await Promise.all( + ( + Object.entries(specs) as Array< + [OsProtocolSecondaryRole, { name: string; unit: string }] + > + ).map(async ([role, spec]) => { + const row = await ( + await database.prepare( + 'SELECT id FROM stat_attributes WHERE active=1 AND name=? COLLATE NOCASE AND unit=?', + ) + ).get(spec.name, spec.unit); + return [role, row ? String(row.id) : null] as const; + }), + ); + return Object.fromEntries(entries) as Record< + OsProtocolSecondaryRole, + string | null + >; +} diff --git a/src/lib/server/shared-build-summary.ts b/src/lib/server/shared-build-summary.ts index dc26770..2fc4c56 100644 --- a/src/lib/server/shared-build-summary.ts +++ b/src/lib/server/shared-build-summary.ts @@ -1,5 +1,6 @@ import type { Database } from '@tursodatabase/database'; import { attachWeaponCategoryBonuses } from '$lib/weapons/category-bonuses'; +import { hydrateSkillChipProfileRows } from '$lib/skill-chips/profiles'; export async function loadSharedBuildSummaryCatalog(database: Database) { const [ @@ -16,6 +17,10 @@ export async function loadSharedBuildSummaryCatalog(database: Database) { talentTemplates, chipSets, chipTwoPiece, + chipPrimaryOptions, + chipPrimaryQualityValues, + chipSecondaryOptions, + chipSecondaryQualityValues, chipTalents, chipTalentEffects, protocols, @@ -83,6 +88,18 @@ export async function loadSharedBuildSummaryCatalog(database: Database) { ( await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses') ).all(), + (await database.prepare('SELECT * FROM skill_chip_primary_options')).all(), + ( + await database.prepare('SELECT * FROM skill_chip_primary_quality_values') + ).all(), + ( + await database.prepare('SELECT * FROM skill_chip_secondary_options') + ).all(), + ( + await database.prepare( + 'SELECT * FROM skill_chip_secondary_quality_values', + ) + ).all(), (await database.prepare('SELECT * FROM skill_chip_talents')).all(), ( await database.prepare( @@ -161,6 +178,16 @@ export async function loadSharedBuildSummaryCatalog(database: Database) { ), })), })), + skillChipProfile: { + primaryOptions: hydrateSkillChipProfileRows( + chipPrimaryOptions, + chipPrimaryQualityValues, + ), + secondaryOptions: hydrateSkillChipProfileRows( + chipSecondaryOptions, + chipSecondaryQualityValues, + ), + }, chipSets: chipSets.map((set) => { const talent = chipTalents.find( (item) => item.skill_chip_set_id === set.id, diff --git a/src/lib/server/skill-chip-profiles.ts b/src/lib/server/skill-chip-profiles.ts new file mode 100644 index 0000000..0d5472c --- /dev/null +++ b/src/lib/server/skill-chip-profiles.ts @@ -0,0 +1,117 @@ +import type { Database } from '@tursodatabase/database'; +import { + SKILL_CHIP_PRIMARY_ATTRIBUTE_NAMES, + SKILL_CHIP_QUALITIES, + SKILL_CHIP_SECONDARY_QUALITIES, + type SkillChipProfile, +} from '$lib/skill-chips/profiles'; + +export async function getSkillChipPrimaryAttributeIds(database: Database) { + const rows = await ( + await database.prepare( + `SELECT id,name FROM stat_attributes + WHERE active=1 AND name IN (${SKILL_CHIP_PRIMARY_ATTRIBUTE_NAMES.map(() => '?').join(',')}) + ORDER BY name COLLATE NOCASE`, + ) + ).all(...SKILL_CHIP_PRIMARY_ATTRIBUTE_NAMES); + if (rows.length !== SKILL_CHIP_PRIMARY_ATTRIBUTE_NAMES.length) + throw new Error( + 'All four fixed Skill Chip primary Attributes must be active.', + ); + return rows.map((row) => String(row.id)); +} + +async function getProfiles( + database: Database, + optionTable: string, + valueTable: string, +) { + const [options, values] = await Promise.all([ + ( + await database.prepare( + `SELECT stat_attribute_id FROM ${optionTable} ORDER BY stat_attribute_id`, + ) + ).all(), + ( + await database.prepare( + `SELECT * FROM ${valueTable} ORDER BY stat_attribute_id,quality`, + ) + ).all(), + ]); + if (!options.length) return []; + return options.map((option) => ({ + statId: String(option.stat_attribute_id), + values: Object.fromEntries( + values + .filter((value) => value.stat_attribute_id === option.stat_attribute_id) + .map((value) => [ + String(value.quality), + { min: Number(value.min_value), max: Number(value.max_value) }, + ]), + ), + })); +} + +export const getSkillChipPrimaryProfiles = (database: Database) => + getProfiles( + database, + 'skill_chip_primary_options', + 'skill_chip_primary_quality_values', + ); + +export const getSkillChipSecondaryPool = (database: Database) => + getProfiles( + database, + 'skill_chip_secondary_options', + 'skill_chip_secondary_quality_values', + ); + +async function replaceProfiles( + database: Database, + optionTable: string, + valueTable: string, + profiles: SkillChipProfile[], + qualities: readonly string[], +) { + await database.exec(`DELETE FROM ${valueTable}`); + await database.exec(`DELETE FROM ${optionTable}`); + for (const profile of profiles) { + await ( + await database.prepare( + `INSERT INTO ${optionTable}(stat_attribute_id) VALUES(?)`, + ) + ).run(profile.statId); + for (const quality of qualities) { + const range = profile.values[quality]; + await ( + await database.prepare( + `INSERT INTO ${valueTable}(stat_attribute_id,quality,min_value,max_value) VALUES(?,?,?,?)`, + ) + ).run(profile.statId, quality, range.min, range.max); + } + } +} + +export const replaceSkillChipPrimaryProfiles = ( + database: Database, + profiles: SkillChipProfile[], +) => + replaceProfiles( + database, + 'skill_chip_primary_options', + 'skill_chip_primary_quality_values', + profiles, + SKILL_CHIP_QUALITIES, + ); + +export const replaceSkillChipSecondaryPool = ( + database: Database, + profiles: SkillChipProfile[], +) => + replaceProfiles( + database, + 'skill_chip_secondary_options', + 'skill_chip_secondary_quality_values', + profiles, + SKILL_CHIP_SECONDARY_QUALITIES, + ); diff --git a/src/lib/server/specialization-readiness.ts b/src/lib/server/specialization-readiness.ts index d9831f9..7bcf42e 100644 --- a/src/lib/server/specialization-readiness.ts +++ b/src/lib/server/specialization-readiness.ts @@ -17,37 +17,51 @@ export async function refreshSpecializationFocusReady( abilityCount: 0, chipReadyCount: 0, }; - const [config, abilityCountRow, chipReadyRow, nodes, rankProfiles] = - await Promise.all([ - ( - await database.prepare( - "SELECT xp_points,shd_cache_points FROM specialization_progression_config WHERE id='default'", - ) - ).get(), - ( - await database.prepare(`SELECT COUNT(*) AS count FROM specialization_focus_abilities link + const [ + config, + abilityCountRow, + profileRow, + chipReadyRow, + nodes, + rankProfiles, + ] = await Promise.all([ + ( + await database.prepare( + "SELECT xp_points,shd_cache_points FROM specialization_progression_config WHERE id='default'", + ) + ).get(), + ( + await database.prepare(`SELECT COUNT(*) AS count FROM specialization_focus_abilities link JOIN specialization_abilities ability ON ability.id=link.ability_id WHERE link.focus_id=? AND ability.active=1`) - ).get(focusId), - ( - await database.prepare(`SELECT COUNT(*) AS count FROM specialization_focus_abilities link + ).get(focusId), + ( + await database.prepare(`SELECT + (SELECT COUNT(*) FROM skill_chip_primary_options)=4 + AND (SELECT COUNT(*) FROM skill_chip_primary_quality_values)=16 + AND (SELECT COUNT(*) FROM skill_chip_secondary_options)>=2 + AND (SELECT COUNT(*) FROM skill_chip_secondary_quality_values)= + (SELECT COUNT(*)*3 FROM skill_chip_secondary_options) AS ready`) + ).get(), + ( + await database.prepare(`SELECT COUNT(*) AS count FROM specialization_focus_abilities link JOIN specialization_abilities ability ON ability.id=link.ability_id AND ability.active=1 WHERE link.focus_id=? AND ( SELECT COUNT(*) FROM skill_chip_sets chips WHERE chips.specialization_ability_id=ability.id AND chips.active=1 )=3`) - ).get(focusId), - ( - await database.prepare( - 'SELECT row_number,max_rank FROM specialization_tree_nodes WHERE focus_id=? AND active=1 ORDER BY row_number,column_number', - ) - ).all(focusId), - ( - await database.prepare( - 'SELECT rank,point_cost FROM specialization_rank_cost_profiles ORDER BY rank', - ) - ).all(), - ]); + ).get(focusId), + ( + await database.prepare( + 'SELECT row_number,max_rank FROM specialization_tree_nodes WHERE focus_id=? AND active=1 ORDER BY row_number,column_number', + ) + ).all(focusId), + ( + await database.prepare( + 'SELECT rank,point_cost FROM specialization_rank_cost_profiles ORDER BY rank', + ) + ).all(), + ]); const pointForRank = new Map( rankProfiles.map((profile) => [ Number(profile.rank), @@ -69,6 +83,7 @@ export async function refreshSpecializationFocusReady( Number(focus.active) === 1 && abilityCount === 3 && chipReadyCount === 3 && + Number(profileRow?.ready) === 1 && nodes.length > 0 && contiguousRows && pointTotal === availablePoints; diff --git a/src/lib/skill-chips/profiles.test.ts b/src/lib/skill-chips/profiles.test.ts new file mode 100644 index 0000000..6d66169 --- /dev/null +++ b/src/lib/skill-chips/profiles.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from 'vitest'; +import { + normalizeSkillChipProfiles, + SKILL_CHIP_PRIMARY_ATTRIBUTE_NAMES, + SKILL_CHIP_QUALITIES, + SKILL_CHIP_SECONDARY_CAPACITY, + SKILL_CHIP_SECONDARY_QUALITIES, +} from './profiles'; + +const primaryIds = [ + 'skill-duration', + 'skill-radius', + 'skill-cooldown-recovery', + 'skill-intensity', +] as const; + +const ranges = (qualities: readonly string[], offset = 0) => + Object.fromEntries( + qualities.map((quality, index) => [ + quality, + { min: String(offset + index + 1), max: offset + index + 2 }, + ]), + ); + +const profile = (statId: string, qualities: readonly string[], offset = 0) => ({ + statId, + values: ranges(qualities, offset), +}); + +describe('Skill Chip shared profiles', () => { + it('defines the exact four fixed primary Attributes and all four qualities', () => { + expect(SKILL_CHIP_PRIMARY_ATTRIBUTE_NAMES).toEqual([ + 'Skill Duration', + 'Skill Radius', + 'Skill Cooldown Recovery', + 'Skill Intensity', + ]); + expect(SKILL_CHIP_QUALITIES).toEqual([ + 'Standard', + 'Specialized', + 'Superior', + 'High-end', + ]); + }); + + it('defines the 0/1/2/2 secondary capacity mapping', () => { + expect(SKILL_CHIP_SECONDARY_CAPACITY).toEqual({ + Standard: 0, + Specialized: 1, + Superior: 2, + 'High-end': 2, + }); + }); + + it('normalizes exactly the four fixed primary profiles across every quality', () => { + const raw = primaryIds.map((statId, index) => + profile(` ${statId} `, SKILL_CHIP_QUALITIES, index * 10), + ); + + const result = normalizeSkillChipProfiles(raw, SKILL_CHIP_QUALITIES, { + expectedStatIds: primaryIds, + minimumOptions: 4, + }); + + expect(result.error).toBeNull(); + expect(result.profiles).toHaveLength(4); + expect(result.profiles.map(({ statId }) => statId)).toEqual(primaryIds); + expect(result.profiles[0]).toEqual({ + statId: 'skill-duration', + values: { + Standard: { min: 1, max: 2 }, + Specialized: { min: 2, max: 3 }, + Superior: { min: 3, max: 4 }, + 'High-end': { min: 4, max: 5 }, + }, + }); + }); + + it('rejects a primary profile that does not contain the exact fixed set', () => { + const raw = primaryIds.map((statId) => + profile(statId, SKILL_CHIP_QUALITIES), + ); + raw[3] = profile('weapon-damage', SKILL_CHIP_QUALITIES); + + expect( + normalizeSkillChipProfiles(raw, SKILL_CHIP_QUALITIES, { + expectedStatIds: primaryIds, + minimumOptions: 4, + }), + ).toEqual({ + profiles: [], + error: + 'The primary profile must contain the four fixed Skill Attributes.', + }); + }); + + it('normalizes secondary profiles across only the three supported qualities', () => { + expect(SKILL_CHIP_SECONDARY_QUALITIES).toEqual([ + 'Specialized', + 'Superior', + 'High-end', + ]); + const result = normalizeSkillChipProfiles( + [ + profile(' critical-hit-chance ', SKILL_CHIP_SECONDARY_QUALITIES), + profile('critical-hit-damage', SKILL_CHIP_SECONDARY_QUALITIES, 10), + ], + SKILL_CHIP_SECONDARY_QUALITIES, + { minimumOptions: 2 }, + ); + + expect(result.error).toBeNull(); + expect(result.profiles[0]).toEqual({ + statId: 'critical-hit-chance', + values: { + Specialized: { min: 1, max: 2 }, + Superior: { min: 2, max: 3 }, + 'High-end': { min: 3, max: 4 }, + }, + }); + expect(result.profiles[0].values).not.toHaveProperty('Standard'); + }); + + it('rejects duplicate Attributes after trimming IDs', () => { + const result = normalizeSkillChipProfiles( + [ + profile('critical-hit-chance', SKILL_CHIP_SECONDARY_QUALITIES), + profile(' critical-hit-chance ', SKILL_CHIP_SECONDARY_QUALITIES), + ], + SKILL_CHIP_SECONDARY_QUALITIES, + { minimumOptions: 2 }, + ); + + expect(result).toEqual({ + profiles: [], + error: 'Choose a unique Attribute for every shared option.', + }); + }); + + it.each([ + [ + 'missing ranges', + { + Specialized: { min: 1, max: 2 }, + Superior: { min: 2, max: 3 }, + }, + 'High-end requires a minimum and maximum.', + ], + [ + 'reversed ranges', + { + Specialized: { min: 2, max: 1 }, + Superior: { min: 2, max: 3 }, + 'High-end': { min: 3, max: 4 }, + }, + 'Specialized requires a valid non-negative range.', + ], + [ + 'negative ranges', + { + Specialized: { min: -1, max: 2 }, + Superior: { min: 2, max: 3 }, + 'High-end': { min: 3, max: 4 }, + }, + 'Specialized requires a valid non-negative range.', + ], + ])('rejects %s', (_label, values, error) => { + const result = normalizeSkillChipProfiles( + [ + { statId: 'critical-hit-chance', values }, + profile('critical-hit-damage', SKILL_CHIP_SECONDARY_QUALITIES), + ], + SKILL_CHIP_SECONDARY_QUALITIES, + { minimumOptions: 2 }, + ); + + expect(result).toEqual({ profiles: [], error }); + }); +}); diff --git a/src/lib/skill-chips/profiles.ts b/src/lib/skill-chips/profiles.ts new file mode 100644 index 0000000..04a33de --- /dev/null +++ b/src/lib/skill-chips/profiles.ts @@ -0,0 +1,127 @@ +export const SKILL_CHIP_QUALITIES = [ + 'Standard', + 'Specialized', + 'Superior', + 'High-end', +] as const; + +export const SKILL_CHIP_SECONDARY_QUALITIES = [ + 'Specialized', + 'Superior', + 'High-end', +] as const; + +export const SKILL_CHIP_SECONDARY_CAPACITY = { + Standard: 0, + Specialized: 1, + Superior: 2, + 'High-end': 2, +} as const satisfies Record<(typeof SKILL_CHIP_QUALITIES)[number], number>; + +export const SKILL_CHIP_PRIMARY_ATTRIBUTE_NAMES = [ + 'Skill Duration', + 'Skill Radius', + 'Skill Cooldown Recovery', + 'Skill Intensity', +] as const; + +export type SkillChipRange = { min: number; max: number }; +export type SkillChipProfile = { + statId: string; + values: Record; +}; + +export function hydrateSkillChipProfileRows( + options: Array>, + qualityValues: Array>, +) { + return options.map((option) => ({ + statId: String(option.stat_attribute_id), + ranges: Object.fromEntries( + qualityValues + .filter((range) => range.stat_attribute_id === option.stat_attribute_id) + .map((range) => [ + String(range.quality), + { min: Number(range.min_value), max: Number(range.max_value) }, + ]), + ), + })); +} + +const finite = (value: unknown) => { + if (value === '' || value === null || value === undefined) return null; + const number = Number(value); + return Number.isFinite(number) ? number : null; +}; + +export function normalizeSkillChipProfiles( + raw: unknown, + qualities: readonly string[], + options: { + expectedStatIds?: readonly string[]; + minimumOptions?: number; + } = {}, +): { profiles: SkillChipProfile[]; error: string | null } { + if (!Array.isArray(raw)) + return { profiles: [], error: 'The shared profile payload is invalid.' }; + if (raw.length < (options.minimumOptions ?? 1) || raw.length > 50) + return { + profiles: [], + error: `Configure ${options.minimumOptions ?? 1}–50 shared Attribute options.`, + }; + + const profiles: SkillChipProfile[] = []; + const seen = new Set(); + for (const entry of raw) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) + return { profiles: [], error: 'A shared Attribute option is invalid.' }; + const candidate = entry as Record; + const statId = String(candidate.statId ?? '').trim(); + if (!statId || seen.has(statId)) + return { + profiles: [], + error: 'Choose a unique Attribute for every shared option.', + }; + seen.add(statId); + const rawValues = candidate.values; + if (!rawValues || typeof rawValues !== 'object' || Array.isArray(rawValues)) + return { + profiles: [], + error: 'Every shared Attribute needs quality ranges.', + }; + const values: Record = {}; + for (const quality of qualities) { + const rawRange = (rawValues as Record)[quality]; + if (!rawRange || typeof rawRange !== 'object' || Array.isArray(rawRange)) + return { + profiles: [], + error: `${quality} requires a minimum and maximum.`, + }; + const range = rawRange as Record; + const min = finite(range.min); + const max = finite(range.max); + if (min === null || max === null || min < 0 || max < min) + return { + profiles: [], + error: `${quality} requires a valid non-negative range.`, + }; + values[quality] = { min, max }; + } + profiles.push({ statId, values }); + } + + if (options.expectedStatIds) { + const expected = [...options.expectedStatIds].sort(); + const received = profiles.map((profile) => profile.statId).sort(); + if ( + expected.length !== received.length || + expected.some((statId, index) => statId !== received[index]) + ) + return { + profiles: [], + error: + 'The primary profile must contain the four fixed Skill Attributes.', + }; + } + return { profiles, error: null }; +} diff --git a/src/lib/weapons/mod-slots.test.ts b/src/lib/weapons/mod-slots.test.ts index 3cd8e1d..70f6d15 100644 --- a/src/lib/weapons/mod-slots.test.ts +++ b/src/lib/weapons/mod-slots.test.ts @@ -1,10 +1,15 @@ import { describe, expect, it } from 'vitest'; import { + WEAPON_MOD_SLOT_DEFINITIONS, + WEAPON_MOD_SLOT_TYPES, excludedTierZeroWeaponModSlot, expandSharedWeaponModSlots, normalizeWeaponModSlotPayload, + publicWeaponModSlots, sharedWeaponModSlots, validateWeaponModSlots, + weaponModSlotDefinitionById, + weaponModSlotDefinitionByName, weaponModSlotsComplete, type WeaponModSlotConfig, } from './mod-slots'; @@ -15,6 +20,23 @@ const configuration = (tier: number, slots: string[]): WeaponModSlotConfig => ({ }); describe('weapon mod slot configuration', () => { + it('assigns one stable UUID to every canonical slot type', () => { + expect(WEAPON_MOD_SLOT_DEFINITIONS).toHaveLength(8); + expect(new Set(WEAPON_MOD_SLOT_DEFINITIONS.map(({ id }) => id)).size).toBe( + 8, + ); + expect(WEAPON_MOD_SLOT_DEFINITIONS.map(({ name }) => name)).toEqual( + WEAPON_MOD_SLOT_TYPES, + ); + for (const definition of WEAPON_MOD_SLOT_DEFINITIONS) { + expect(definition.id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + expect(weaponModSlotDefinitionById(definition.id)).toBe(definition); + expect(weaponModSlotDefinitionByName(definition.name)).toBe(definition); + } + }); + it('accepts missing and partial slot data for later configuration', () => { const slots = [ configuration(0, []), @@ -99,4 +121,31 @@ describe('weapon mod slot configuration', () => { ]), ).toEqual([configuration(1, ['Optics', 'Muzzle', 'Barrel'])]); }); + + it('marks the slot gained by a native T0 weapon at T1', () => { + const slots = expandSharedWeaponModSlots( + ['Magazine', 'Optics', 'Barrel', 'Muzzle', 'Underbarrel'], + 'Barrel', + ); + + expect(publicWeaponModSlots(0, slots)).toEqual([ + { slot: 'Optics', unlockedAtTierOne: false }, + { slot: 'Muzzle', unlockedAtTierOne: false }, + { slot: 'Underbarrel', unlockedAtTierOne: false }, + { slot: 'Magazine', unlockedAtTierOne: false }, + { slot: 'Barrel', unlockedAtTierOne: true }, + ]); + }); + + it('shows native T1 slots without an upgrade-only state', () => { + expect( + publicWeaponModSlots(1, [ + configuration(1, ['Magazine', 'Optics', 'Muzzle']), + ]), + ).toEqual([ + { slot: 'Optics', unlockedAtTierOne: false }, + { slot: 'Muzzle', unlockedAtTierOne: false }, + { slot: 'Magazine', unlockedAtTierOne: false }, + ]); + }); }); diff --git a/src/lib/weapons/mod-slots.ts b/src/lib/weapons/mod-slots.ts index c74567a..e7b4191 100644 --- a/src/lib/weapons/mod-slots.ts +++ b/src/lib/weapons/mod-slots.ts @@ -1,19 +1,42 @@ -export const WEAPON_MOD_SLOT_TYPES = [ - 'Optics', - 'Muzzle', - 'Underbarrel', - 'Magazine', - 'Barrel', - 'Upper Receiver', - 'Lower Receiver', - 'Trigger', +export const WEAPON_MOD_SLOT_DEFINITIONS = [ + { id: '28ffc3b1-5d98-4273-a9af-67dbff3e5c76', name: 'Optics' }, + { id: 'c4598f51-2cb0-4a20-a3a7-6d8983a26598', name: 'Muzzle' }, + { id: '85ca3493-1871-4d22-950a-ed718fd547e0', name: 'Underbarrel' }, + { id: '4d809a35-8285-4bff-be98-a0effe1d257e', name: 'Magazine' }, + { id: '0e157cfe-975c-441d-8f49-72983161e7b1', name: 'Barrel' }, + { id: '551fd7c8-3e5b-4c89-8437-9b901e89e8aa', name: 'Upper Receiver' }, + { id: '72573367-6e91-4312-9cc3-710ede4f078e', name: 'Lower Receiver' }, + { id: '8dda5edb-6079-4a23-a556-cd9be4521c70', name: 'Trigger' }, ] as const; +export type WeaponModSlotType = + (typeof WEAPON_MOD_SLOT_DEFINITIONS)[number]['name']; + +export const WEAPON_MOD_SLOT_TYPES = WEAPON_MOD_SLOT_DEFINITIONS.map( + (definition) => definition.name, +) as WeaponModSlotType[]; + +export function weaponModSlotDefinitionById(id: string) { + return WEAPON_MOD_SLOT_DEFINITIONS.find((definition) => definition.id === id); +} + +export function weaponModSlotDefinitionByName(name: string) { + return WEAPON_MOD_SLOT_DEFINITIONS.find( + (definition) => definition.name === name, + ); +} + export type WeaponModSlotConfig = { tier: number; slots: string[]; }; +export type PublicWeaponModSlot = { + slot: string; + unlockedAtTierOne: boolean; + iconUrl?: string; +}; + export function normalizeWeaponModSlotPayload( payload: unknown, ): WeaponModSlotConfig[] { @@ -52,6 +75,33 @@ export function sharedWeaponModSlots( ]; } +export function publicWeaponModSlots( + nativeTier: number, + configurations: WeaponModSlotConfig[], +): PublicWeaponModSlot[] { + const nativeSlots = + configurations.find((row) => Number(row.tier) === nativeTier)?.slots ?? []; + const displayedSlots = + nativeTier === 0 + ? sharedWeaponModSlots(configurations).length + ? sharedWeaponModSlots(configurations) + : nativeSlots + : nativeSlots; + const ordered = [...new Set(displayedSlots)].sort( + (left, right) => + WEAPON_MOD_SLOT_TYPES.indexOf( + left as (typeof WEAPON_MOD_SLOT_TYPES)[number], + ) - + WEAPON_MOD_SLOT_TYPES.indexOf( + right as (typeof WEAPON_MOD_SLOT_TYPES)[number], + ), + ); + return ordered.map((slot) => ({ + slot, + unlockedAtTierOne: nativeTier === 0 && !nativeSlots.includes(slot), + })); +} + export function excludedTierZeroWeaponModSlot( sharedSlots: string[], configurations: WeaponModSlotConfig[], diff --git a/src/routes/agents/[id]/+page.server.ts b/src/routes/agents/[id]/+page.server.ts index 2fb67ef..2d15a6c 100644 --- a/src/routes/agents/[id]/+page.server.ts +++ b/src/routes/agents/[id]/+page.server.ts @@ -92,6 +92,10 @@ export const load: PageServerLoad = async ({ params, cookies, url }) => { WHERE users.id=?`) ).get(agentId); const isSelf = viewer?.id === agentId; + const canManageNews = + isSelf && + (viewerPermissions.includes('*') || + viewerPermissions.includes('news.manage')); if (unavailable(agent) || (!Boolean(agent?.is_public) && !isSelf)) error(404, 'Agent profile not found'); if (params.id !== String(agent.public_slug)) { @@ -129,6 +133,9 @@ export const load: PageServerLoad = async ({ params, cookies, url }) => { profileCommentCount, buildCount, guideCount, + ownedBuildRows, + ownedGuideRows, + ownedNewsRows, ] = await Promise.all([ ( await database.prepare(`SELECT roles.name FROM user_roles @@ -216,6 +223,28 @@ export const load: PageServerLoad = async ({ params, cookies, url }) => { await database.prepare(`SELECT COUNT(*) AS count FROM articles WHERE kind='guide' AND owner_id=? AND visibility='public' AND deleted_at IS NULL`) ).get(agentId), + isSelf + ? ( + await database.prepare(`SELECT id,slug,title,visibility,updated_at + FROM builds WHERE owner_id=? ORDER BY updated_at DESC`) + ).all(agentId) + : [], + isSelf + ? ( + await database.prepare(`SELECT articles.id,articles.slug,draft.title,articles.visibility,articles.updated_at + FROM articles JOIN article_drafts draft ON draft.article_id=articles.id + WHERE articles.kind='guide' AND articles.owner_id=? AND articles.deleted_at IS NULL + ORDER BY articles.updated_at DESC`) + ).all(agentId) + : [], + canManageNews + ? ( + await database.prepare(`SELECT articles.id,articles.slug,draft.title,articles.visibility,articles.updated_at + FROM articles JOIN article_drafts draft ON draft.article_id=articles.id + WHERE articles.kind='news' AND articles.owner_id=? AND articles.deleted_at IS NULL + ORDER BY articles.updated_at DESC`) + ).all(agentId) + : [], ]); const loadedCommentIds = new Set( @@ -401,6 +430,32 @@ export const load: PageServerLoad = async ({ params, cookies, url }) => { title: String(guide.title), updatedAt: String(guide.updated_at), })), + ownedContent: isSelf + ? { + builds: ownedBuildRows.map((build) => ({ + id: String(build.id), + slug: String(build.slug), + title: String(build.title), + visibility: String(build.visibility), + updatedAt: String(build.updated_at), + })), + guides: ownedGuideRows.map((guide) => ({ + id: String(guide.id), + slug: String(guide.slug), + title: String(guide.title), + visibility: String(guide.visibility), + updatedAt: String(guide.updated_at), + })), + news: ownedNewsRows.map((news) => ({ + id: String(news.id), + slug: String(news.slug), + title: String(news.title), + visibility: String(news.visibility), + updatedAt: String(news.updated_at), + })), + canManageNews, + } + : null, authoredComments: authoredCommentRows.map((comment) => { const targetType = String(comment.target_type); const targetId = String(comment.target_id); diff --git a/src/routes/agents/[id]/+page.svelte b/src/routes/agents/[id]/+page.svelte index 91b48ef..804be3f 100644 --- a/src/routes/agents/[id]/+page.svelte +++ b/src/routes/agents/[id]/+page.svelte @@ -32,6 +32,7 @@ title: string; updatedAt: string; }; + type OwnedContent = PublicContent & { visibility: string }; type AuthoredComment = { id: string; createdAt: string; @@ -60,6 +61,12 @@ counts: { builds: number; guides: number; authoredComments: number; profileComments: number }; builds: PublicContent[]; guides: PublicContent[]; + ownedContent: { + builds: OwnedContent[]; + guides: OwnedContent[]; + news: OwnedContent[]; + canManageNews: boolean; + } | null; authoredComments: AuthoredComment[]; profileComments: ProfileComment[]; viewer: { id: string | null; canComment: boolean; canReportProfile: boolean; isSelf: boolean }; @@ -86,6 +93,7 @@ ? 'DATE UNKNOWN' : date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }).toUpperCase(); }; + const visibilityLabel = (visibility: string) => visibility.toUpperCase().replace('_', ' '); $effect(() => { const token = String(form?.profileCommentResultToken ?? form?.commentResultToken ?? ''); @@ -242,6 +250,44 @@
    + + {#if data.viewer.isSelf && data.ownedContent} +
    +
    +

    OWNER WORKSPACE

    CONTENT CONTROL

    + PRIVATE TO YOU +
    +

    Open any authored item to edit it or change its visibility. Private and unlisted items appear only here.

    +
    +
    +
    LOADOUTSNEW BUILD +
    +
    + {#each data.ownedContent.builds as build} + {build.title}UPDATED {dateLabel(build.updatedAt)}{visibilityLabel(build.visibility)}EDIT → + {:else}

    No builds yet. Create one in the Loadout Lab.

    {/each} +
    +
    +
    +
    GUIDESNEW GUIDE +
    +
    + {#each data.ownedContent.guides as guide} + {guide.title}UPDATED {dateLabel(guide.updatedAt)}{visibilityLabel(guide.visibility)}EDIT → + {:else}

    No Guides yet. Start a field guide when you are ready.

    {/each} +
    +
    + {#if data.ownedContent.canManageNews} +
    +
    NEWS POSTSNEW ARTICLE +
    +
    + {#each data.ownedContent.news as news} + {news.title}UPDATED {dateLabel(news.updatedAt)}{visibilityLabel(news.visibility)}EDIT → + {:else}

    No news posts authored by this Agent.

    {/each} +
    +
    + {/if} +
    +
    + {/if}
    @@ -392,6 +438,7 @@ .profile-section{min-width:0;border:1px solid var(--shd-border);background:var(--shd-surface)}.profile-section>header{min-height:54px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:9px 12px;border-bottom:1px solid var(--shd-border);background:var(--shd-surface-raised)}.profile-section>header>div{display:grid;gap:2px}.profile-section>header :is(h1,h2,p){margin:0}.profile-section>header h1,.profile-section>header h2{font-size:var(--shd-type-body);letter-spacing:.4px}.profile-section>header>span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px} .bio-content{min-height:110px;padding:18px}:global(form.profile-bio-form){display:grid;gap:10px;padding:16px}.profile-bio-actions{display:flex;justify-content:flex-end;gap:7px}.profile-bio-actions button{min-height:40px}.profile-owner-notice{display:grid;gap:4px;margin:12px 16px 0;padding:10px;border:1px solid #745b2b;background:#1c170e;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.profile-owner-notice strong{color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.7px}.profile-owner-notice.error{border-color:#70423b;background:#211414}.profile-owner-notice.error strong{color:var(--shd-danger)}.owner-profile-result{margin:10px 16px 0}.public-content-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px;padding:7px} .public-content-card{min-width:0;display:grid;grid-template-rows:auto auto minmax(48px,1fr) auto;gap:6px;padding:12px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-orange);background:#0d1011;color:var(--shd-text)}.public-content-card>span,.public-content-card footer{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.public-content-card>span{color:var(--shd-orange)}.public-content-card h3,.public-content-card p{margin:0}.public-content-card h3{font-size:1.05rem}.public-content-card p{color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:var(--shd-type-supporting);line-height:1.45}.public-content-card footer{display:flex;justify-content:space-between;gap:8px;padding-top:8px;border-top:1px solid var(--shd-border-soft)}.public-content-card small{color:var(--shd-text-dim)}.public-content-card b{color:var(--shd-orange)}.public-content-card:hover,.public-content-card:focus-visible{border-color:var(--shd-orange);background:var(--shd-surface-active)} + .content-control{margin-top:10px}.content-control-intro{margin:0;padding:10px 12px;border-bottom:1px solid var(--shd-border);color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:var(--shd-type-supporting)}.content-control-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:7px;padding:7px}.content-control-grid>section{min-width:0;border:1px solid var(--shd-border);background:#0d1011}.content-control-grid>section>header{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:8px 10px;border-bottom:1px solid var(--shd-border);background:var(--shd-surface-raised)}.content-control-grid>section>header :is(span,a){font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.content-control-grid>section>header span{color:var(--shd-text-muted)}.content-control-grid>section>header a,.content-control-list strong{color:var(--shd-orange)}.content-control-list{display:grid}.content-control-list>a{min-width:0;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:7px;padding:10px;border-top:1px solid var(--shd-border);color:var(--shd-text)}.content-control-list>a:first-child{border-top:0}.content-control-list>a>span{min-width:0;display:grid;gap:3px}.content-control-list b{overflow:hidden;font-size:var(--shd-type-supporting);text-overflow:ellipsis;white-space:nowrap}.content-control-list small,.content-control-list em,.content-control-list strong{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.65px}.content-control-list small{color:var(--shd-text-dim)}.content-control-list em{justify-self:start;padding:2px 5px;border:1px solid var(--shd-border-strong);color:var(--shd-text-muted);font-style:normal}.content-control-list strong{grid-column:2;grid-row:1 / span 2;align-self:center}.content-control-list>a:hover,.content-control-list>a:focus-visible{background:var(--shd-surface-active);outline:1px solid var(--shd-orange);outline-offset:-1px}.content-control-empty{min-height:92px;display:grid;place-content:center;margin:0;padding:12px;color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:var(--shd-type-supporting);text-align:center} .public-empty{grid-column:1/-1;min-height:150px;display:grid;place-content:center;gap:6px;padding:20px;text-align:center}.public-empty span{color:var(--shd-orange);font-size:28px}.public-empty strong{font-size:var(--shd-type-telemetry);letter-spacing:1px}.public-empty p{margin:0;color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:var(--shd-type-supporting)}.public-empty.compact{min-height:110px} .authored-comment-list{display:grid;gap:7px;padding:7px}.authored-comment-list>article{display:grid;gap:10px;padding:11px;border:1px solid var(--shd-border);background:#0d1011}.authored-comment-list article>header{position:static;z-index:auto;width:auto;height:auto;min-height:0;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:10px;padding:0;border:0;background:transparent}.authored-comment-list article>header>div{min-width:0;display:grid;align-content:center;gap:2px}.authored-comment-list article header span,.authored-comment-list article header small,.authored-comment-list article>a{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.authored-comment-list article header span,.authored-comment-list article>a{color:var(--shd-orange)}.authored-comment-list article header small{align-self:center;color:var(--shd-text-muted);text-align:right;white-space:nowrap}.authored-comment-list article>a{justify-self:end} .profile-comms{margin-top:16px;padding:0;background:transparent}.profile-comms-heading{position:static;z-index:auto;width:auto;height:auto;min-height:0;display:flex;align-items:end;justify-content:space-between;gap:16px;padding:0 0 12px;border:0;border-bottom:1px solid var(--shd-border);background:transparent}.profile-comms-heading>div{display:grid;gap:3px}.profile-comms-heading :is(h2,p){margin:0}.profile-comms-heading h2{font-size:1.25rem}.profile-comms-heading>span{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.profile-comment-start{width:min(920px,100%);padding-top:16px}.profile-comment-trigger{width:100%;min-height:48px;padding:0 14px;border:1px solid var(--shd-border-strong);background:transparent;color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:var(--shd-type-body);text-align:left;cursor:pointer}.profile-comment-trigger:hover,.profile-comment-trigger:focus-visible,.profile-comment-trigger[aria-expanded=true]{border-color:var(--shd-orange);color:var(--shd-text);background:var(--shd-surface-active)}:global(form.profile-comment-form){width:min(920px,100%);display:grid;gap:9px;padding:14px 0 0;border:0;background:transparent}.profile-comment-form-actions{display:flex;justify-content:flex-end;gap:7px}.profile-comment-form-actions button{min-height:38px}.profile-comment-list{width:min(980px,100%);display:grid;margin-top:26px;overflow:visible}.profile-comment-list>article{--thread-depth:0;position:relative;display:grid;gap:8px;margin:18px 0 0 calc(var(--thread-depth) * 22px);padding:0;scroll-margin-top:96px;background:transparent}.profile-comment-list>article:target{outline:1px solid var(--shd-orange);outline-offset:8px}.profile-comment-list>article.reply{padding-left:14px;border-left:1px solid var(--shd-border-strong)}.profile-comment-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}.profile-comment-list article>header :global(.agent-identity){flex:0 1 auto}.profile-comment-list article :global(.markdown-content){max-width:75ch;margin-left:47px}.comment-actions{display:flex;align-items:center;gap:14px;min-height:32px;margin-left:47px}.comment-reply{min-height:32px;padding:0;border:0;background:transparent;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.6px;cursor:pointer}.comment-reply:hover,.comment-reply:focus-visible,.comment-reply[aria-expanded=true]{color:var(--shd-orange);text-decoration:underline;text-underline-offset:3px}.reply-composer{margin:6px 0 0 47px}.reply-composer :global(form.profile-comment-form){padding-top:0}.removed-message{display:grid;gap:3px;padding:4px 0}.removed-message strong{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.removed-message span{color:var(--shd-text-dim);font-size:var(--shd-type-supporting);font-style:italic}.comment-delete-confirm{width:min(520px,calc(100% - 47px));display:grid;gap:8px;margin-left:47px;padding:10px;border:1px solid var(--shd-danger);background:#1b1111}.comment-delete-confirm p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.comment-delete-confirm>div{display:flex;justify-content:flex-end;gap:7px}.comment-delete-confirm button{min-height:38px;padding:0 10px}.comment-access-note{width:min(920px,100%);display:grid;gap:7px;padding:16px 0 0}.comment-access-note strong{color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.comment-access-note p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.comment-access-note a{justify-self:start}.profile-comms .public-empty{min-height:100px;place-content:start;padding:24px 0;text-align:left}.profile-comms .public-empty span{display:none} @@ -399,6 +446,8 @@ .profile-comment-list article :global(.markdown-content),.comment-actions{margin-left:53px}.reply-composer{margin-left:53px}.comment-delete-confirm{width:min(520px,calc(100% - 53px));margin-left:53px} .pinned-comment-label{margin-left:auto;color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.7px} @media(max-width:1000px){.agent-profile-grid{grid-template-columns:1fr}.agent-main-column{grid-column:1}.agent-activity{position:static;grid-column:1;grid-template-columns:repeat(2,minmax(0,1fr))}.agent-activity .profile-section:last-child{grid-column:1/-1}.agent-telemetry{grid-template-columns:repeat(2,minmax(0,1fr))}.agent-telemetry>div:nth-child(3){border-left:0}.agent-telemetry>div:nth-child(n+3){border-top:1px solid var(--shd-border)}} + @media(max-width:1000px){.content-control-grid{grid-template-columns:repeat(2,minmax(0,1fr))}} + @media(max-width:650px){.content-control-grid{grid-template-columns:1fr}} @media(max-width:650px){.agent-command{grid-template-columns:1fr;align-items:stretch}.agent-command-actions{width:100%;display:grid;grid-template-columns:minmax(0,1fr) auto}.agent-command-actions :global(.profile-visibility-form),.agent-command-actions .visibility-toggle{width:100%}.agent-command-actions .visibility-toggle{min-height:44px;justify-content:center}.agent-command-actions :global(.content-report){grid-column:1/-1}.agent-command-actions :global(.content-report summary){width:100%;justify-content:center}.mobile-account-jump{grid-column:1/-1;min-height:44px;display:flex;align-items:center;justify-content:center;border:1px solid var(--shd-border-strong);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.mobile-account-jump:hover,.mobile-account-jump:focus-visible{border-color:var(--shd-orange);color:var(--shd-orange);background:var(--shd-surface-active)}.agent-main-column{display:contents}.agent-primary{grid-column:1;grid-row:1}.agent-activity{grid-column:1;grid-row:2;grid-template-columns:1fr}.profile-comms{grid-column:1;grid-row:3;margin-top:16px}.agent-activity .profile-section:last-child{grid-column:auto}.agent-published-grid{grid-template-columns:1fr}.public-content-grid{grid-template-columns:1fr}.profile-section>header,.profile-comms-heading{align-items:flex-start;flex-direction:column}.agent-bio>header{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:start}.agent-bio .profile-header-actions{width:auto}.bio-edit-trigger{min-height:44px;padding:0 5px;border-color:transparent}.bio-edit-trigger:hover,.bio-edit-trigger:focus-visible,.bio-edit-trigger[aria-expanded=true]{border-color:var(--shd-border-strong)}.profile-bio-actions{display:grid;grid-template-columns:1fr 1fr}.profile-bio-actions button{min-height:44px}.profile-comment-trigger,.profile-comment-form-actions button,.comment-reply,.comment-delete-confirm button{min-height:44px}.profile-comment-form-actions{display:grid;grid-template-columns:1fr 1fr}.profile-comment-list>article{margin-left:calc(var(--thread-depth) * 12px)}.comment-delete-confirm>div{display:grid;grid-template-columns:1fr 1fr}} @media(prefers-reduced-motion:reduce){.public-content-card,.profile-comment-trigger,.comment-reply{transition:none}} diff --git a/src/routes/api/isac/calculation-assistant/+server.ts b/src/routes/api/isac/calculation-assistant/+server.ts index b44eaaa..aae7d43 100644 --- a/src/routes/api/isac/calculation-assistant/+server.ts +++ b/src/routes/api/isac/calculation-assistant/+server.ts @@ -60,7 +60,10 @@ export const POST: RequestHandler = async ({ cookies, request, url }) => { } try { const authoringRequest = parseAiCalculationAuthoringRequest(payload); - const release = reserveAiMechanicGeneration(operator.id); + const release = reserveAiMechanicGeneration( + operator.id, + operator.role === 'admin', + ); let response; try { response = await generateAiCalculationDraft( diff --git a/src/routes/api/isac/mechanic-assistant/+server.ts b/src/routes/api/isac/mechanic-assistant/+server.ts index e57f479..abf4318 100644 --- a/src/routes/api/isac/mechanic-assistant/+server.ts +++ b/src/routes/api/isac/mechanic-assistant/+server.ts @@ -58,7 +58,10 @@ export const POST: RequestHandler = async ({ cookies, request, url }) => { } try { const authoringRequest = parseAiMechanicAuthoringRequest(payload); - const release = reserveAiMechanicGeneration(operator.id); + const release = reserveAiMechanicGeneration( + operator.id, + operator.role === 'admin', + ); let response; try { response = await generateAiMechanicDraft(authoringRequest, operator.id); diff --git a/src/routes/builder/+page.server.ts b/src/routes/builder/+page.server.ts index 9a29d15..24b14e6 100644 --- a/src/routes/builder/+page.server.ts +++ b/src/routes/builder/+page.server.ts @@ -3,10 +3,11 @@ import { gameDataIconUrl } from '$lib/server/game-data-icons'; import { getSessionUser } from '$lib/server/auth/session'; import { fail } from '@sveltejs/kit'; import { randomUUID } from 'node:crypto'; -import { parseLoadoutDraft } from '$lib/builder/loadout'; +import { parseLoadoutDraft, type LoadoutDraft } from '$lib/builder/loadout'; import { publicOrigin } from '$lib/server/public-origin'; import type { Actions, PageServerLoad } from './$types'; import { attachWeaponCategoryBonuses } from '$lib/weapons/category-bonuses'; +import { hydrateSkillChipProfileRows } from '$lib/skill-chips/profiles'; const validVisibility = new Set(['private', 'unlisted', 'public']); const slugify = (value: string) => @@ -28,6 +29,102 @@ function parseLoadout(raw: FormDataEntryValue | null) { } } +async function validateSkillChipSelections( + database: Awaited>, + loadout: LoadoutDraft, +) { + if (!loadout.chips.some((chip) => chip.setId)) return null; + const [ + sets, + focusAbilities, + primaryOptions, + primaryValues, + secondaryOptions, + secondaryValues, + ] = await Promise.all([ + ( + await database.prepare( + 'SELECT id,specialization_ability_id FROM skill_chip_sets WHERE active=1', + ) + ).all(), + ( + await database.prepare( + 'SELECT ability_id,ability_slot FROM specialization_focus_abilities WHERE focus_id=? ORDER BY ability_slot', + ) + ).all(loadout.specialization.focusId), + ( + await database.prepare( + 'SELECT stat_attribute_id FROM skill_chip_primary_options', + ) + ).all(), + ( + await database.prepare('SELECT * FROM skill_chip_primary_quality_values') + ).all(), + ( + await database.prepare( + 'SELECT stat_attribute_id FROM skill_chip_secondary_options', + ) + ).all(), + ( + await database.prepare( + 'SELECT * FROM skill_chip_secondary_quality_values', + ) + ).all(), + ]); + const primaryIds = new Set( + primaryOptions.map((row) => String(row.stat_attribute_id)), + ); + const secondaryIds = new Set( + secondaryOptions.map((row) => String(row.stat_attribute_id)), + ); + const rangeFor = (rows: any[], statId: string, quality: string) => + rows.find( + (row) => + String(row.stat_attribute_id) === statId && row.quality === quality, + ); + const inRange = (value: number, range: any) => + range && + value >= Number(range.min_value) && + value <= Number(range.max_value); + + for (let index = 0; index < loadout.chips.length; index += 1) { + const chip = loadout.chips[index]; + if (!chip.setId) continue; + const definition = sets.find((row) => String(row.id) === chip.setId); + const expectedAbility = focusAbilities.find( + (row) => Number(row.ability_slot) === Math.floor(index / 3) + 1, + ); + if ( + !definition || + !expectedAbility || + String(definition.specialization_ability_id) !== + String(expectedAbility.ability_id) + ) + return `Skill Chip ${index + 1} is not compatible with its assigned ability.`; + const primaryRange = rangeFor( + primaryValues, + chip.primary.statId, + chip.quality, + ); + if ( + !primaryIds.has(chip.primary.statId) || + !inRange(chip.primary.value, primaryRange) + ) + return `Skill Chip ${index + 1} has an invalid primary Attribute value.`; + if ( + new Set(chip.secondary.map((roll) => roll.statId)).size !== + chip.secondary.length + ) + return `Skill Chip ${index + 1} must use distinct secondary Attributes.`; + for (const roll of chip.secondary) { + const range = rangeFor(secondaryValues, roll.statId, chip.quality); + if (!secondaryIds.has(roll.statId) || !inRange(roll.value, range)) + return `Skill Chip ${index + 1} has an invalid secondary Attribute value.`; + } + } + return null; +} + export const load: PageServerLoad = async ({ cookies, url }) => { const database = await getDatabase(); const viewer = await getSessionUser(cookies); @@ -70,8 +167,10 @@ export const load: PageServerLoad = async ({ cookies, url }) => { talentTemplates, chipSets, chipTwoPiece, - chipPrimary, - chipSecondary, + chipPrimaryOptions, + chipPrimaryQualityValues, + chipSecondaryOptions, + chipSecondaryQualityValues, chipTalents, chipTalentEffects, protocols, @@ -162,9 +261,25 @@ export const load: PageServerLoad = async ({ cookies, url }) => { ( await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses') ).all(), - (await database.prepare('SELECT * FROM skill_chip_primary_options')).all(), ( - await database.prepare('SELECT * FROM skill_chip_secondary_options') + await database.prepare( + 'SELECT stat_attribute_id FROM skill_chip_primary_options ORDER BY stat_attribute_id', + ) + ).all(), + ( + await database.prepare( + 'SELECT stat_attribute_id,quality,min_value,max_value FROM skill_chip_primary_quality_values ORDER BY stat_attribute_id,quality', + ) + ).all(), + ( + await database.prepare( + 'SELECT stat_attribute_id FROM skill_chip_secondary_options ORDER BY stat_attribute_id', + ) + ).all(), + ( + await database.prepare( + 'SELECT stat_attribute_id,quality,min_value,max_value FROM skill_chip_secondary_quality_values ORDER BY stat_attribute_id,quality', + ) ).all(), (await database.prepare('SELECT * FROM skill_chip_talents')).all(), ( @@ -329,6 +444,16 @@ export const load: PageServerLoad = async ({ cookies, url }) => { weaponBonuses, ), talents: configuredTalents, + skillChipProfile: { + primaryOptions: hydrateSkillChipProfileRows( + chipPrimaryOptions, + chipPrimaryQualityValues, + ), + secondaryOptions: hydrateSkillChipProfileRows( + chipSecondaryOptions, + chipSecondaryQualityValues, + ), + }, chipSets: chipSets.map((set) => { const talent = chipTalents.find( (item) => item.skill_chip_set_id === set.id, @@ -339,12 +464,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => { twoPiece: chipTwoPiece.find((item) => item.skill_chip_set_id === set.id) ?? null, - primaryStatIds: chipPrimary - .filter((item) => item.skill_chip_set_id === set.id) - .map((item) => String(item.stat_attribute_id)), - secondaryStatIds: chipSecondary - .filter((item) => item.skill_chip_set_id === set.id) - .map((item) => String(item.stat_attribute_id)), talent: talent ? { ...talent, @@ -473,6 +592,8 @@ export const actions: Actions = { return fail(400, { buildError: 'Choose a valid build visibility.' }); const database = await getDatabase(); + const skillChipError = await validateSkillChipSelections(database, loadout); + if (skillChipError) return fail(400, { buildError: skillChipError }); const now = new Date().toISOString(); let buildId = id; let slug = ''; diff --git a/src/routes/builder/+page.svelte b/src/routes/builder/+page.svelte index 15c0bc8..5f078f4 100644 --- a/src/routes/builder/+page.svelte +++ b/src/routes/builder/+page.svelte @@ -15,8 +15,8 @@ import { summarizeLoadout } from '$lib/builder/loadout-summary'; import { toast } from '$lib/toast'; import { - blankRoll, clamp, createDraft, gearBaseStatRange, gearSlots, levelScaledRange, parseLoadoutDraft, remapRangeValue, scaled, weaponBaseRange, - type LoadoutDraft, type Roll + blankRoll, clamp, createDraft, gearBaseStatRange, gearSlots, levelScaledRange, parseLoadoutDraft, remapRangeValue, scaled, skillChipQualities, skillChipSecondaryCapacity, weaponBaseRange, + type LoadoutDraft, type Roll, type SkillChipQuality } from '$lib/builder/loadout'; let { data, form } = $props(); @@ -35,8 +35,23 @@ const coreKeys = ['firepower','toughness','engineering'] as const; const gearAttributes = $derived(data.attributes.filter((item) => item.gear_min !== null).map((item) => ({ ...item, category: `${item.type} · ${item.unit || 'FLAT'}` }))); const weaponAttributes = $derived(data.attributes.filter((item) => item.weapon_min !== null).map((item) => ({ ...item, category: `${item.type} · ${item.unit || 'FLAT'}` }))); - const chipAttributes = $derived(data.attributes.filter((item) => item.skill_chip_min !== null).map((item) => ({ ...item, category: `${item.type} · ${item.unit || 'FLAT'}` }))); const attribute = (id: string) => data.attributes.find((item) => item.id === id); + const chipPrimaryAttributes = $derived(data.skillChipProfile.primaryOptions.map((option: any) => attribute(option.statId)).filter(Boolean).map((item: any) => ({ ...item, category: `${item.type} · ${item.unit || 'FLAT'}` }))); + const chipSecondaryAttributes = $derived(data.skillChipProfile.secondaryOptions.map((option: any) => attribute(option.statId)).filter(Boolean).map((item: any) => ({ ...item, category: `${item.type} · ${item.unit || 'FLAT'}` }))); + const chipRange = (kind: 'primary' | 'secondary', statId: string, quality: SkillChipQuality) => + data.skillChipProfile[`${kind}Options`].find((option: any) => option.statId === statId)?.ranges?.[quality] ?? null; + type ChipRange = { min: number; max: number }; + const validChipRange = (range: any): range is ChipRange => Number.isFinite(Number(range?.min)) && Number.isFinite(Number(range?.max)) && Number(range.max) >= Number(range.min); + const chipRangeMin = (kind: 'primary' | 'secondary', statId: string, quality: SkillChipQuality) => Number(chipRange(kind, statId, quality)?.min ?? 0); + const chipRangeMax = (kind: 'primary' | 'secondary', statId: string, quality: SkillChipQuality) => Number(chipRange(kind, statId, quality)?.max ?? 0); + const chipProfileConfigured = $derived( + data.skillChipProfile.primaryOptions.length === 4 + && chipPrimaryAttributes.length === data.skillChipProfile.primaryOptions.length + && data.skillChipProfile.primaryOptions.every((option: any) => skillChipQualities.every((quality) => validChipRange(option.ranges?.[quality]))) + && data.skillChipProfile.secondaryOptions.length >= 2 + && chipSecondaryAttributes.length === data.skillChipProfile.secondaryOptions.length + && data.skillChipProfile.secondaryOptions.every((option: any) => ['Specialized','Superior','High-end'].every((quality) => validChipRange(option.ranges?.[quality]))) + ); const gearItem = (slot: string) => data.gear.find((item) => item.id === draft.gear[slot].itemId); const weaponItem = (index: number) => data.weapons.find((item) => item.id === draft.weapons[index].itemId); const chipItem = (index: number) => data.chipSets.find((item) => item.id === draft.chips[index].setId); @@ -80,13 +95,35 @@ coreKeys.reduce((total, key) => total + Number(selection[key] ?? 0), 0); const attributeRange = (stat: any, source: 'gear' | 'weapon', level: number) => scaledRange(stat?.[`${source}_min`], stat?.[`${source}_max`], level, stat?.[`${source}_reference_level`], source); - const setRollStat = (roll: Roll, id: string, source: 'gear' | 'weapon' | 'chip', level = 50) => { + const setRollStat = (roll: Roll, id: string, source: 'gear' | 'weapon', level = 50) => { roll.statId = id; const stat = attribute(id); - const min = source === 'chip' - ? stat?.skill_chip_min - : attributeRange(stat, source, level).min; - roll.value = Number(min ?? 0); + roll.value = Number(attributeRange(stat, source, level).min ?? 0); + }; + const setChipRoll = (roll: Roll, id: string, kind: 'primary' | 'secondary', quality: SkillChipQuality) => { + roll.statId = id; + const range = chipRange(kind, id, quality); + roll.value = validChipRange(range) ? Number(range.min) : 0; + }; + const chipRollConfigured = (roll: Roll, kind: 'primary' | 'secondary', quality: SkillChipQuality) => { + const options = data.skillChipProfile[`${kind}Options`]; + const range = chipRange(kind, roll.statId, quality); + const value = Number(roll.value); + return options.some((option: any) => option.statId === roll.statId) + && validChipRange(range) + && Number.isFinite(value) + && value >= Number(range.min) + && value <= Number(range.max); + }; + const chipSelectionConfigured = (chip: LoadoutDraft['chips'][number]) => { + const capacity = skillChipSecondaryCapacity[chip.quality]; + const secondaryIds = chip.secondary.map((roll) => roll.statId); + return Boolean(data.chipSets.some((item: any) => item.id === chip.setId)) + && chipProfileConfigured + && chipRollConfigured(chip.primary, 'primary', chip.quality) + && chip.secondary.length === capacity + && new Set(secondaryIds).size === secondaryIds.length + && chip.secondary.every((roll) => chipRollConfigured(roll, 'secondary', chip.quality)); }; const rankPointCost = (rank: number) => Number(data.specializationProgression.rankProfiles.find((profile: any) => Number(profile.rank) === rank)?.point_cost ?? rank); const selectedSpecializationPoints = $derived(selectedFocus?.nodes.reduce((total: number, node: any) => { @@ -222,15 +259,41 @@ selection.rolls = [blankRoll(), blankRoll()]; }; const configureChip = (index: number, id: string) => { - const chip = draft.chips[index], item = data.chipSets.find((entry) => entry.id === id); + const chip = draft.chips[index]; chip.setId = id; - chip.primary = { statId: item?.primaryStatIds[0] ?? '', value: 0 }; - chip.secondary = [ - { statId: item?.secondaryStatIds[0] ?? '', value: 0 }, - { statId: item?.secondaryStatIds[1] ?? '', value: 0 } - ]; - if (chip.primary.statId) setRollStat(chip.primary, chip.primary.statId, 'chip'); - for (const roll of chip.secondary) if (roll.statId) setRollStat(roll, roll.statId, 'chip'); + chip.primary = blankRoll(); + chip.secondary = []; + changeChipQuality(index, chip.quality); + }; + const changeChipQuality = (index: number, quality: SkillChipQuality) => { + if (!skillChipQualities.includes(quality)) return; + const chip = draft.chips[index]; + chip.quality = quality; + const primaryOption = data.skillChipProfile.primaryOptions.find((option: any) => option.statId === chip.primary.statId && validChipRange(option.ranges?.[quality])) + ?? data.skillChipProfile.primaryOptions.find((option: any) => validChipRange(option.ranges?.[quality])); + if (primaryOption) { + const range = primaryOption.ranges[quality]; + chip.primary = { + statId: primaryOption.statId, + value: clamp(chip.primary.statId === primaryOption.statId ? chip.primary.value : range.min, range.min, range.max) + }; + } else chip.primary = blankRoll(); + const capacity = skillChipSecondaryCapacity[quality]; + const next: Roll[] = []; + for (const roll of chip.secondary) { + if (next.length >= capacity || next.some((entry) => entry.statId === roll.statId)) continue; + const range = chipRange('secondary', roll.statId, quality); + if (!validChipRange(range)) continue; + next.push({ statId: roll.statId, value: clamp(roll.value, range.min, range.max) }); + } + for (const option of data.skillChipProfile.secondaryOptions) { + if (next.length >= capacity) break; + if (next.some((entry) => entry.statId === option.statId)) continue; + const range = option.ranges?.[quality]; + if (validChipRange(range)) next.push({ statId: option.statId, value: Number(range.min) }); + } + while (next.length < capacity) next.push(blankRoll()); + chip.secondary = next; }; const changeWeaponTier = (index: number, tier: number) => { const selection = draft.weapons[index], item = weaponItem(index); @@ -297,7 +360,8 @@ }); const abilityCells = $derived.by((): MatrixCell[] => [0, 1, 2].map((index) => { const ability = selectedFocus?.abilities[index]; - const configuredChips = draft.chips.slice(index * 3, index * 3 + 3).filter((chip) => chip.setId); + const abilityChips = draft.chips.slice(index * 3, index * 3 + 3); + const configuredChips = abilityChips.filter((chip) => chip.setId); const chipNames = configuredChips.map((chip) => data.chipSets.find((item) => item.id === chip.setId)?.name).filter(Boolean); return { id: `ability:${index}`, @@ -306,7 +370,7 @@ meta: ability?.attack?.damage_type ?? '', summary: chipNames.length ? chipNames.join(' · ') : `${configuredChips.length} / 3 SKILL CHIPS`, iconUrl: ability?.iconUrl, - issue: !ability ? 'ABILITY NOT CONFIGURED' : configuredChips.length < 3 ? `${3 - configuredChips.length} CHIP${configuredChips.length === 2 ? '' : 'S'} REQUIRED` : undefined + issue: !ability ? 'ABILITY NOT CONFIGURED' : configuredChips.length < 3 ? `${3 - configuredChips.length} CHIP${configuredChips.length === 2 ? '' : 'S'} REQUIRED` : abilityChips.some((chip) => !chipSelectionConfigured(chip)) ? 'CONFIG REQUIRED' : undefined }; })); const weaponCells = $derived.by((): MatrixCell[] => draft.weapons.map((selection, index) => { @@ -357,14 +421,15 @@ && selectedFocus.abilities.length === 3 && draft.weapons.every((item) => item.itemId) && gearSlots.every((slot) => draft.gear[slot].itemId) - && draft.chips.every((item) => item.setId) + && draft.chips.every(chipSelectionConfigured) && draft.protocol.itemId )); onMount(() => { - if (data.initialBuild) return; localStorage.removeItem('shd-build-loadout-draft-v3'); - const stored = localStorage.getItem('shd-build-loadout-draft-v4'); + localStorage.removeItem('shd-build-loadout-draft-v4'); + if (data.initialBuild) return; + const stored = localStorage.getItem('shd-build-loadout-draft-v5'); if (!stored) return; try { const parsed = parseLoadoutDraft(JSON.parse(stored)); @@ -372,9 +437,9 @@ draft = parsed; if (weaponItem(0) && weaponItem(1) && Number(weaponItem(0).is_exotic) && Number(weaponItem(1).is_exotic)) draft.weapons[1].itemId = ''; saved = true; - } else localStorage.removeItem('shd-build-loadout-draft-v4'); + } else localStorage.removeItem('shd-build-loadout-draft-v5'); } catch { - localStorage.removeItem('shd-build-loadout-draft-v4'); + localStorage.removeItem('shd-build-loadout-draft-v5'); } }); $effect(() => { @@ -392,7 +457,7 @@ : 'Build published to the SHD archive.'; }); function saveDraft() { - localStorage.setItem('shd-build-loadout-draft-v4', JSON.stringify(draft)); + localStorage.setItem('shd-build-loadout-draft-v5', JSON.stringify(draft)); saved = true; toast.success('Draft saved locally', 'This loadout is available on this device.'); } @@ -548,7 +613,27 @@
    ABILITY {abilityIndex + 1} · CHIP {(index % 3) + 1}

    {ability?.name?.toUpperCase() ?? 'SKILL CHIP'}

    {chip.quality.toUpperCase()}
    {#if ability} entry.specialization_ability_id === ability.id)} bind:value={chip.setId} onchange={(id) => configureChip(index,id)} label="ABILITY-COMPATIBLE SKILL CHIP SET"/>{:else}

    Select a focus with three configured class abilities before equipping Skill Chips.

    {/if} - {#if item}
    item.primaryStatIds.includes(entry.id))} bind:value={chip.primary.statId} onchange={(id) => setRollStat(chip.primary,id,'chip')} label="PRIMARY ATTRIBUTE"/>
    {#each chip.secondary as roll,index}
    item.secondaryStatIds.includes(entry.id))} bind:value={roll.statId} onchange={(id) => setRollStat(roll,id,'chip')} disabledIds={chip.secondary.filter((_,i) => i !== index).map((entry) => entry.statId)} label={`SECONDARY ATTRIBUTE ${index + 1}`}/>
    {/each}
    {/if} + {#if !chipProfileConfigured}

    SHARED SKILL CHIP PROFILE · CONFIG REQUIRED. Complete every global quality range in ISAC before this loadout can be complete.

    {/if} + {#if item} +
    + ({ value: quality, label: quality.toUpperCase() }))} value={chip.quality} onchange={(values) => changeChipQuality(index, values[0] as SkillChipQuality)} required/> +
    +
    ABILITY-SCOPED PRIMARY{ability?.name?.toUpperCase() ?? 'ASSIGNED ABILITY'} ONLY · EXCLUDED FROM KNOWN AGENT STATS
    NOT YET DEFINED
    +

    The primary roll belongs to this ability. Its calculation application is NOT YET DEFINED, so it does not contribute to current Agent totals.

    +
    +
    + setChipRoll(chip.primary,id,'primary',chip.quality)} label="PRIMARY ATTRIBUTE"/> + {#if validChipRange(chipRange('primary', chip.primary.statId, chip.quality))}{:else}

    PRIMARY RANGE · CONFIG REQUIRED

    {/if} +
    + {#each chip.secondary as roll, secondaryIndex} +
    + setChipRoll(roll,id,'secondary',chip.quality)} disabledIds={chip.secondary.filter((_,i) => i !== secondaryIndex).map((entry) => entry.statId).filter(Boolean)} label={`SECONDARY ATTRIBUTE ${secondaryIndex + 1}`}/> + {#if validChipRange(chipRange('secondary', roll.statId, chip.quality))}{:else}

    SECONDARY RANGE · CONFIG REQUIRED

    {/if} +
    + {/each} +
    + {#if skillChipSecondaryCapacity[chip.quality] === 0}

    STANDARD QUALITY · NO SECONDARY ATTRIBUTES

    {/if} + {/if} {:else} {@const protocol = protocolItem()}
    AGENT SYSTEM

    OS PROTOCOL

    {protocol?.quality?.toUpperCase() ?? 'UNASSIGNED'}
    diff --git a/src/routes/builder/skill-chip-save.test.ts b/src/routes/builder/skill-chip-save.test.ts new file mode 100644 index 0000000..73fb312 --- /dev/null +++ b/src/routes/builder/skill-chip-save.test.ts @@ -0,0 +1,186 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createDraft, type LoadoutDraft } from '$lib/builder/loadout'; + +const mocks = vi.hoisted(() => ({ + insertBuild: vi.fn(), + getSessionUser: vi.fn(async () => ({ + id: 'agent-1', + displayName: 'Test Agent', + })), + database: { + prepare: vi.fn(), + }, +})); + +vi.mock('$lib/server/auth/session', () => ({ + getSessionUser: mocks.getSessionUser, +})); + +vi.mock('$lib/server/database', () => ({ + getDatabase: vi.fn(async () => mocks.database), +})); + +import { actions } from './+page.server'; + +const primaryStatId = 'skill-duration'; +const secondaryStatIds = ['critical-hit-chance', 'critical-hit-damage']; + +const validLoadout = (): LoadoutDraft => { + const loadout = createDraft(); + loadout.buildName = 'Validated Skill Chips'; + loadout.specialization.focusId = 'focus-1'; + loadout.chips[0] = { + setId: 'chip-set-1', + quality: 'Superior', + primary: { statId: primaryStatId, value: 15 }, + secondary: [ + { statId: secondaryStatIds[0], value: 2 }, + { statId: secondaryStatIds[1], value: 4 }, + ], + }; + return loadout; +}; + +const saveRequest = (loadout: LoadoutDraft) => { + const form = new FormData(); + form.set('loadout', JSON.stringify(loadout)); + form.set('visibility', 'private'); + return new Request('http://localhost/builder?/save', { + method: 'POST', + body: form, + }); +}; + +const runSave = (loadout: LoadoutDraft) => + actions.save?.({ + cookies: {} as never, + request: saveRequest(loadout), + } as never); + +describe('Builder Skill Chip save validation', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.database.prepare.mockImplementation(async (sql: string) => ({ + all: vi.fn(async () => { + if (sql.includes('FROM skill_chip_sets')) + return [ + { + id: 'chip-set-1', + specialization_ability_id: 'ability-1', + }, + ]; + if (sql.includes('FROM specialization_focus_abilities')) + return [{ ability_id: 'ability-1', ability_slot: 1 }]; + if (sql.includes('FROM skill_chip_primary_options')) + return [{ stat_attribute_id: primaryStatId }]; + if (sql.includes('FROM skill_chip_primary_quality_values')) + return [ + { + stat_attribute_id: primaryStatId, + quality: 'Superior', + min_value: 10, + max_value: 20, + }, + ]; + if (sql.includes('FROM skill_chip_secondary_options')) + return secondaryStatIds.map((stat_attribute_id) => ({ + stat_attribute_id, + })); + if (sql.includes('FROM skill_chip_secondary_quality_values')) + return secondaryStatIds.map((stat_attribute_id) => ({ + stat_attribute_id, + quality: 'Superior', + min_value: 1, + max_value: 5, + })); + return []; + }), + run: mocks.insertBuild, + })); + }); + + it('rejects a selected Skill Chip assigned to a different focus ability', async () => { + mocks.database.prepare.mockImplementation(async (sql: string) => ({ + all: vi.fn(async () => { + if (sql.includes('FROM skill_chip_sets')) + return [ + { + id: 'chip-set-1', + specialization_ability_id: 'ability-2', + }, + ]; + if (sql.includes('FROM specialization_focus_abilities')) + return [{ ability_id: 'ability-1', ability_slot: 1 }]; + return []; + }), + run: mocks.insertBuild, + })); + + const result = await runSave(validLoadout()); + + expect(result).toMatchObject({ + status: 400, + data: { + buildError: 'Skill Chip 1 is not compatible with its assigned ability.', + }, + }); + expect(mocks.insertBuild).not.toHaveBeenCalled(); + }); + + it.each([ + { + label: 'primary', + mutate: (loadout: LoadoutDraft) => { + loadout.chips[0].primary.value = 21; + }, + message: 'Skill Chip 1 has an invalid primary Attribute value.', + }, + { + label: 'secondary', + mutate: (loadout: LoadoutDraft) => { + loadout.chips[0].secondary[1].value = 6; + }, + message: 'Skill Chip 1 has an invalid secondary Attribute value.', + }, + ])('rejects an out-of-range $label value', async ({ mutate, message }) => { + const loadout = validLoadout(); + mutate(loadout); + + const result = await runSave(loadout); + + expect(result).toMatchObject({ + status: 400, + data: { buildError: message }, + }); + expect(mocks.insertBuild).not.toHaveBeenCalled(); + }); + + it('rejects duplicate secondary Attributes', async () => { + const loadout = validLoadout(); + loadout.chips[0].secondary[1].statId = secondaryStatIds[0]; + + const result = await runSave(loadout); + + expect(result).toMatchObject({ + status: 400, + data: { + buildError: 'Skill Chip 1 must use distinct secondary Attributes.', + }, + }); + expect(mocks.insertBuild).not.toHaveBeenCalled(); + }); + + it('accepts primary and secondary values within the configured profiles', async () => { + const result = await runSave(validLoadout()); + + expect(result).toMatchObject({ + buildSaved: true, + buildToken: expect.any(String), + savedBuild: { + visibility: 'private', + title: 'Validated Skill Chips', + }, + }); + expect(mocks.insertBuild).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/routes/builds/[slug]/+page.svelte b/src/routes/builds/[slug]/+page.svelte index 15f49aa..57478f9 100644 --- a/src/routes/builds/[slug]/+page.svelte +++ b/src/routes/builds/[slug]/+page.svelte @@ -64,7 +64,7 @@
    AGENT SYSTEMS10 SLOTS
    - {#each data.loadout.chips as selection, index}{@const item=chip(selection.setId)}
    SKILL CHIP 0{index+1}{item?.name ?? 'EMPTY SLOT'}{item ? selection.quality : 'NOT CONFIGURED'}{#if item}{rollLine([selection.primary, ...selection.secondary]) || 'ATTRIBUTES NOT CONFIGURED'}{/if}
    {/each} + {#each data.loadout.chips as selection, index}{@const item=chip(selection.setId)}
    SKILL CHIP 0{index+1}{item?.name ?? 'EMPTY SLOT'}{item ? selection.quality : 'NOT CONFIGURED'}{#if item}ABILITY-SCOPED PRIMARY · {rollLine([selection.primary]) || 'NOT CONFIGURED'} · NOT YET DEFINED{selection.secondary.length ? ` · SECONDARY · ${rollLine(selection.secondary)}` : ''}{/if}
    {/each} {#if protocol(data.loadout.protocol.itemId)}{@const protocolItem=protocol(data.loadout.protocol.itemId)}
    OS PROTOCOL{protocolItem.name}LEVEL {data.loadout.protocol.level} · {data.loadout.protocol.talentUpgrades}/5 UPGRADES
    {:else}
    OS PROTOCOLEMPTY SLOTNOT CONFIGURED
    {/if}
    diff --git a/src/routes/database/[category]/+page.server.ts b/src/routes/database/[category]/+page.server.ts index 43e1613..455c8dc 100644 --- a/src/routes/database/[category]/+page.server.ts +++ b/src/routes/database/[category]/+page.server.ts @@ -40,6 +40,10 @@ import { recordIsacAudit } from '$lib/server/audit'; import { removePublicComment } from '$lib/server/comment-removal'; import { createNotification } from '$lib/server/notifications'; import type { Actions } from './$types'; +import { + publicWeaponModSlots, + weaponModSlotDefinitionByName, +} from '$lib/weapons/mod-slots'; const number = (value: unknown) => { const numeric = Number(value); @@ -336,8 +340,7 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => { rowsSql = `SELECT item.*,render.id AS render_id,render.updated_at AS render_updated_at, native.damage_min,native.damage_max,native.reference_level, (SELECT COUNT(*) FROM weapon_talent_options options WHERE options.weapon_id=item.id AND options.talent_slot=1) AS talent_1_count, - (SELECT COUNT(*) FROM weapon_talent_options options WHERE options.weapon_id=item.id AND options.talent_slot=2) AS talent_2_count, - (SELECT COUNT(*) FROM weapon_category_attribute_bonuses bonuses WHERE bonuses.weapon_category=item.category) AS bonus_count + (SELECT COUNT(*) FROM weapon_talent_options options WHERE options.weapon_id=item.id AND options.talent_slot=2) AS talent_2_count FROM weapons item LEFT JOIN weapon_tier_ranges native ON native.weapon_id=item.id AND native.tier=item.native_tier LEFT JOIN managed_images render ON render.source_entity_type='weapon_render' AND render.source_entity_id=item.id @@ -386,7 +389,7 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => { rowsSql = `SELECT item.*,weapons.name AS exotic_weapon, render.id AS render_id,render.updated_at AS render_updated_at, GROUP_CONCAT(DISTINCT categories.weapon_category) AS categories, - (SELECT COUNT(*) FROM weapon_mod_secondary_options secondary WHERE secondary.weapon_mod_id=item.id) AS secondary_count, + (SELECT COUNT(*) FROM weapon_mod_secondary_options) AS secondary_count, (SELECT COUNT(*) FROM weapon_mod_effects effects WHERE effects.weapon_mod_id=item.id) AS effect_count FROM weapon_mods item LEFT JOIN weapons ON weapons.id=item.exotic_weapon_id @@ -398,7 +401,7 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => { case 'gear-mods': countSql = `SELECT COUNT(*) AS total FROM gear_mods item WHERE item.active=1${searchClause}`; rowsSql = `SELECT item.*,render.id AS render_id,render.updated_at AS render_updated_at, - (SELECT COUNT(*) FROM gear_mod_secondary_options secondary WHERE secondary.gear_mod_id=item.id) AS secondary_count, + (SELECT COUNT(*) FROM gear_mod_secondary_options) AS secondary_count, (SELECT MIN(primary_level_1_min) FROM gear_mod_quality_values quality WHERE quality.gear_mod_id=item.id) AS level_1_floor, (SELECT MAX(primary_level_20_max) FROM gear_mod_quality_values quality WHERE quality.gear_mod_id=item.id) AS level_20_ceiling FROM gear_mods item @@ -409,7 +412,7 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => { case 'os-protocols': countSql = `SELECT COUNT(*) AS total FROM os_protocols item WHERE item.active=1${searchClause}`; rowsSql = `SELECT item.*,attributes.name AS primary_stat,attributes.unit AS primary_unit, - primary_effect.base_value,talents.name AS talent_name,icons.updated_at AS icon_updated_at, + primary_effect.base_value,talents.name AS talent_name,talents.description_template AS talent_description,icons.updated_at AS icon_updated_at, render.id AS render_id,render.updated_at AS render_updated_at FROM os_protocols item LEFT JOIN os_protocol_primary_effects primary_effect ON primary_effect.protocol_id=item.id @@ -500,8 +503,8 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => { countSql = `SELECT COUNT(*) AS total FROM skill_chip_sets item WHERE item.active=1${searchClause}`; rowsSql = `SELECT item.*,bonus.min_value,bonus.max_value,attributes.name AS bonus_stat,attributes.unit AS bonus_unit, talents.name AS talent_name,abilities.name AS ability_name,specializations.name AS specialization_name, - (SELECT COUNT(*) FROM skill_chip_primary_options options WHERE options.skill_chip_set_id=item.id) AS primary_count, - (SELECT COUNT(*) FROM skill_chip_secondary_options options WHERE options.skill_chip_set_id=item.id) AS secondary_count, + (SELECT COUNT(*) FROM skill_chip_primary_options) AS primary_count, + (SELECT COUNT(*) FROM skill_chip_secondary_options) AS secondary_count, icons.updated_at AS icon_updated_at,render.id AS render_id,render.updated_at AS render_updated_at FROM skill_chip_sets item LEFT JOIN skill_chip_two_piece_bonuses bonus ON bonus.skill_chip_set_id=item.id @@ -549,6 +552,17 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => { const rows = await ( await database.prepare(rowsSql) ).all(...queryArgs, pagination.perPage, pagination.offset); + const protocolTalentEffects = + category.slug === 'os-protocols' && rows.length + ? await ( + await database.prepare(`SELECT talents.protocol_id,effects.base_value,attributes.unit + FROM os_protocol_talents talents + JOIN os_protocol_talent_effects effects ON effects.talent_id=talents.id + JOIN stat_attributes attributes ON attributes.id=effects.stat_attribute_id + WHERE talents.protocol_id IN (${rows.map(() => '?').join(',')}) + ORDER BY talents.protocol_id,effects.sort_order`) + ).all(...rows.map((row) => row.id)) + : []; const weaponRelations = category.slug === 'weapons' && rows.length ? await ( @@ -563,6 +577,22 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => { ORDER BY options.talent_slot,talents.name COLLATE NOCASE`) ).all(...rows.map((row) => row.id)) : []; + const weaponModSlotRows = + category.slug === 'weapons' && rows.length + ? await ( + await database.prepare(`SELECT weapon_id,tier,slot_type FROM weapon_mod_slots + WHERE weapon_id IN (${rows.map(() => '?').join(',')}) + ORDER BY weapon_id,tier,slot_type`) + ).all(...rows.map((row) => row.id)) + : []; + const weaponModSlotIconRows = + category.slug === 'weapons' + ? await ( + await database.prepare( + "SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='weapon_mod_slot'", + ) + ).all() + : []; const gearRelations = category.slug === 'gear' && rows.length ? await ( @@ -769,6 +799,36 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => { const relatedTalents = weaponRelations.filter( (relation) => relation.weapon_id === row.id, ); + const modSlots = publicWeaponModSlots( + Number(row.native_tier), + [0, 1, 2].map((tier) => ({ + tier, + slots: weaponModSlotRows + .filter( + (slot) => + slot.weapon_id === row.id && Number(slot.tier) === tier, + ) + .map((slot) => String(slot.slot_type)), + })), + ).map((slot) => { + const definition = weaponModSlotDefinitionByName(slot.slot); + const icon = definition + ? weaponModSlotIconRows.find( + (row) => row.entity_id === definition.id, + ) + : null; + return { + ...slot, + iconUrl: + definition && icon + ? gameDataIconUrl( + 'weapon_mod_slot', + definition.id, + icon.updated_at, + ) + : '', + }; + }); const talentItems = (slot: number) => relatedTalents .filter((relation) => Number(relation.talent_slot) === slot) @@ -801,11 +861,11 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => { ? managedImageUrl(String(row.render_id), row.render_updated_at) : '', gameIcon: weaponIcon(row.category), - headerBadge: `T${row.native_tier}`, + headerBadges: [ + `T${row.native_tier}`, + ...(Number(row.is_exotic) ? ['EXOTIC'] : []), + ], headerMetaIcon: weaponIcon(row.damage_type), - badges: Number(row.is_exotic) - ? [{ label: 'EXOTIC', tone: 'exotic' }] - : [], factLayout: 'stack' as const, facts: [ { @@ -831,7 +891,7 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => { ], }, ], - lines: [{ label: 'BASE BONUSES', value: String(row.bonus_count) }], + weaponModSlots: modSlots, iconGroups: [ { label: 'TALENT 1 POOL', @@ -914,28 +974,28 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => { ? managedImageUrl(String(row.render_id), row.render_updated_at) : '', iconUrl: iconUrl('os_protocol', row.id, row.icon_updated_at), - badges: [ - { - label: String(row.quality), - tone: String(row.quality).toLowerCase().replace('-', ''), - }, - ], - facts: [ - { label: 'LEVELS', value: '01–50' }, - { - label: 'PRIMARY EFFECT', - value: row.primary_stat - ? `${row.primary_stat} · ${valueWithUnit(row.base_value, row.primary_unit)}` - : 'NOT CONFIGURED', - }, - { label: 'TALENT UPGRADES', value: '0–5' }, - ], - lines: [ - { - label: 'UNIQUE TALENT', - value: list(row.talent_name, 'NOT CONFIGURED'), - }, - ], + topBorderTone: String(row.quality).toLowerCase().replace('-', ''), + headerBadge: String(row.quality).toUpperCase(), + headerBadgeTone: String(row.quality).toLowerCase().replace('-', ''), + factLayout: 'stack' as const, + facts: [], + protocolPreview: { + primaryStat: String(row.primary_stat ?? 'NOT CONFIGURED'), + primaryUnit: String(row.primary_unit ?? ''), + primaryBaseValue: Number(row.base_value), + talentDescription: row.talent_description + ? String(row.talent_description) + : undefined, + talentEffects: protocolTalentEffects + .filter((effect) => effect.protocol_id === row.id) + .map((effect) => ({ + baseValue: Number(effect.base_value), + unit: String(effect.unit ?? ''), + })), + }, + description: row.talent_description + ? String(row.talent_description) + : undefined, }; case 'talents': return talentCardItem(row, mechanicsFor(row.id)); diff --git a/src/routes/isac/attributes/+page.server.ts b/src/routes/isac/attributes/+page.server.ts index 31544d9..054580b 100644 --- a/src/routes/isac/attributes/+page.server.ts +++ b/src/routes/isac/attributes/+page.server.ts @@ -34,8 +34,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => { 'gearMax', 'weaponMin', 'weaponMax', - 'chipMin', - 'chipMax', 'capValue', ], }, @@ -47,8 +45,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => { gear_max: stat.gearMax || null, weapon_min: stat.weaponMin || null, weapon_max: stat.weaponMax || null, - skill_chip_min: stat.chipMin || null, - skill_chip_max: stat.chipMax || null, cap_value: stat.capValue || null, })); const summary = await ( @@ -211,14 +207,9 @@ export const actions: Actions = { let values: Array; try { values = [ - ...[ - 'gearMin', - 'gearMax', - 'weaponMin', - 'weaponMax', - 'chipMin', - 'chipMax', - ].map((key) => number(key)), + ...['gearMin', 'gearMax', 'weaponMin', 'weaponMax'].map((key) => + number(key), + ), number('capValue', false), ]; } catch { @@ -226,7 +217,7 @@ export const actions: Actions = { error: 'Ranges must be numbers. Optional caps cannot be negative.', }); } - for (let index = 0; index < 6; index += 2) + for (let index = 0; index < 4; index += 2) if ( (values[index] === null) !== (values[index + 1] === null) || (values[index] !== null && @@ -253,7 +244,7 @@ export const actions: Actions = { if (id) await ( await database.prepare( - `UPDATE stat_attributes SET name=?,description=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?,weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?,cap_value=?,updated_at=? WHERE id=?`, + `UPDATE stat_attributes SET name=?,description=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?,weapon_min=?,weapon_max=?,cap_value=?,updated_at=? WHERE id=?`, ) ).run( name, @@ -269,7 +260,7 @@ export const actions: Actions = { else await ( await database.prepare( - `INSERT INTO stat_attributes(id,name,description,type,category,calculation_type,unit,gear_min,gear_max,weapon_min,weapon_max,skill_chip_min,skill_chip_max,cap_value,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + `INSERT INTO stat_attributes(id,name,description,type,category,calculation_type,unit,gear_min,gear_max,weapon_min,weapon_max,cap_value,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, ) ).run( statId, diff --git a/src/routes/isac/attributes/+page.svelte b/src/routes/isac/attributes/+page.svelte index 438a79a..2c58e0c 100644 --- a/src/routes/isac/attributes/+page.svelte +++ b/src/routes/isac/attributes/+page.svelte @@ -14,7 +14,7 @@ const categoryOptions = ['Weapon', 'Skill', 'Character'].map((value) => ({ value, label: value })); const calculationOptions = ['Additive', 'Multiplicative'].map((value) => ({ value, label: value })); const unitOptions = [{value:'',label:'None'},{value:'%',label:'%'},{value:'s',label:'s'},{value:'Shots',label:'Shots'},{value:'m',label:'m'}]; - const blank = () => ({id:'',name:'',description:'',type:'Offensive',category:'Weapon',calculation_type:'Additive',unit:'',gear_min:'',gear_max:'',weapon_min:'',weapon_max:'',skill_chip_min:'',skill_chip_max:'',cap_value:''}); + const blank = () => ({id:'',name:'',description:'',type:'Offensive',category:'Weapon',calculation_type:'Additive',unit:'',gear_min:'',gear_max:'',weapon_min:'',weapon_max:'',cap_value:''}); const value = (input: unknown) => input ?? ''; const attributeMeta = (stat: any) => stat.pending ? `PROPOSED BY ${stat.proposerName}` @@ -32,7 +32,7 @@ {#snippet attributeEditor()} {#if editing} -

    {editing.id?'MODIFY RECORD':'NEW RECORD'}

    {editing.id?editing.name:'ATTRIBUTE DEFINITION'}

    {#each [['gear','GEAR'],['weapon','WEAPON'],['skill_chip','SKILL CHIP']] as source}
    {source[1]} RANGE · OPTIONAL
    {/each}
    {#if form?.error}

    {form.error}

    {/if}
    +

    {editing.id?'MODIFY RECORD':'NEW RECORD'}

    {editing.id?editing.name:'ATTRIBUTE DEFINITION'}

    {#each [['gear','GEAR'],['weapon','WEAPON']] as source}
    {source[1]} RANGE · OPTIONAL
    {/each}
    {#if form?.error}

    {form.error}

    {/if}
    {/if} {/snippet} Attributes — SHD.build admin diff --git a/src/routes/isac/audit/+page.server.ts b/src/routes/isac/audit/+page.server.ts index c3a0de2..9606930 100644 --- a/src/routes/isac/audit/+page.server.ts +++ b/src/routes/isac/audit/+page.server.ts @@ -241,7 +241,7 @@ export const actions: Actions = { }); await ( await database.prepare(`UPDATE stat_attributes SET name=?,description=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?, - weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?,cap_value=?,active=?,updated_at=? WHERE id=?`) + weapon_min=?,weapon_max=?,cap_value=?,active=?,updated_at=? WHERE id=?`) ).run( before.name, before.description ?? null, @@ -253,8 +253,6 @@ export const actions: Actions = { before.gear_max, before.weapon_min, before.weapon_max, - before.skill_chip_min, - before.skill_chip_max, before.cap_value, before.active, new Date().toISOString(), diff --git a/src/routes/isac/data-moderation/+page.server.ts b/src/routes/isac/data-moderation/+page.server.ts index d8b66a9..103f2c9 100644 --- a/src/routes/isac/data-moderation/+page.server.ts +++ b/src/routes/isac/data-moderation/+page.server.ts @@ -12,6 +12,12 @@ import { recordIsacAudit } from '$lib/server/audit'; import type { Actions, PageServerLoad } from './$types'; import { createNotification } from '$lib/server/notifications'; import { conditionalRuleSnapshot } from '$lib/server/conditional-effects'; +import { + getSkillChipPrimaryProfiles, + getSkillChipSecondaryPool, +} from '$lib/server/skill-chip-profiles'; +import { gameDataIconMetadata } from '$lib/server/game-data-icons'; +import { weaponModSlotDefinitionById } from '$lib/weapons/mod-slots'; const statuses = ['pending', 'approved', 'rejected', 'failed']; const modules = [ @@ -174,16 +180,6 @@ async function skillChipProposalSnapshot(database: any, id: string) { 'SELECT * FROM skill_chip_two_piece_bonuses WHERE skill_chip_set_id=?', ) ).get(id)) ?? null, - primaryOptions: await ( - await database.prepare( - 'SELECT stat_attribute_id FROM skill_chip_primary_options WHERE skill_chip_set_id=? ORDER BY stat_attribute_id', - ) - ).all(id), - secondaryOptions: await ( - await database.prepare( - 'SELECT stat_attribute_id FROM skill_chip_secondary_options WHERE skill_chip_set_id=? ORDER BY stat_attribute_id', - ) - ).all(id), talent: talent ?? null, talentEffects: talent ? await ( @@ -317,16 +313,35 @@ export const load: PageServerLoad = async ({ cookies, url }) => { ? ((await ( await database.prepare('SELECT * FROM stat_attributes WHERE id=?') ).get(String(proposal.entity_id))) ?? null) - : proposal.module === 'skillchips' && proposal.entity_id - ? await skillChipProposalSnapshot( - database, - String(proposal.entity_id), - ) - : proposal.module === 'calculations' - ? await calculationProposalSnapshot(database, proposal) - : proposal.module === 'specializations' - ? await specializationProposalSnapshot(database, proposal) - : null, + : proposal.module === 'skillchips' && + proposal.action === 'savePrimaryProfile' + ? await getSkillChipPrimaryProfiles(database) + : proposal.module === 'skillchips' && + proposal.action === 'saveSecondaryPool' + ? await getSkillChipSecondaryPool(database) + : proposal.module === 'weaponmods' && + proposal.action === 'saveSlotIcon' && + proposal.entity_id + ? { + slot: weaponModSlotDefinitionById( + String(proposal.entity_id), + ), + icon: await gameDataIconMetadata( + database, + 'weapon_mod_slot', + String(proposal.entity_id), + ), + } + : proposal.module === 'skillchips' && proposal.entity_id + ? await skillChipProposalSnapshot( + database, + String(proposal.entity_id), + ) + : proposal.module === 'calculations' + ? await calculationProposalSnapshot(database, proposal) + : proposal.module === 'specializations' + ? await specializationProposalSnapshot(database, proposal) + : null, })), ); const summaryRows = await ( diff --git a/src/routes/isac/gearmods/+page.server.ts b/src/routes/isac/gearmods/+page.server.ts index 8e89711..c2fa814 100644 --- a/src/routes/isac/gearmods/+page.server.ts +++ b/src/routes/isac/gearmods/+page.server.ts @@ -21,8 +21,16 @@ import { import { fail } from '@sveltejs/kit'; import { randomUUID } from 'node:crypto'; import type { Actions, PageServerLoad } from './$types'; +import { + MOD_QUALITIES, + normalizeModSecondaryPool, +} from '$lib/mods/secondary-pool'; +import { + getModSecondaryPool, + replaceModSecondaryPool, +} from '$lib/server/mod-secondary-pools'; -const qualities = ['Standard', 'Specialized', 'Superior', 'High-end'] as const; +const qualities = MOD_QUALITIES; type QualityValue = { quality: string; primaryLevel1Min: number; @@ -30,11 +38,6 @@ type QualityValue = { primaryLevel20Min: number; primaryLevel20Max: number; }; -type SecondaryRange = { - statId: string; - values: Record; -}; - function parseJson(value: FormDataEntryValue | null, fallback: T): T { try { return JSON.parse(String(value ?? '')) as T; @@ -52,16 +55,6 @@ async function snapshot(id: string) { 'SELECT * FROM gear_mod_quality_values WHERE gear_mod_id=? ORDER BY quality', ) ).all(id), - secondaryOptions: await ( - await database.prepare( - 'SELECT stat_attribute_id FROM gear_mod_secondary_options WHERE gear_mod_id=? ORDER BY stat_attribute_id', - ) - ).all(id), - secondaryQualityValues: await ( - await database.prepare( - 'SELECT * FROM gear_mod_secondary_quality_values WHERE gear_mod_id=? ORDER BY stat_attribute_id,quality', - ) - ).all(id), render: (await ( await database.prepare( @@ -87,10 +80,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => { asc: 'name COLLATE NOCASE ASC,id ASC', desc: 'name COLLATE NOCASE DESC,id DESC', }, - secondaries: { - asc: '(SELECT COUNT(*) FROM gear_mod_secondary_options option WHERE option.gear_mod_id=gear_mods.id) ASC,name COLLATE NOCASE ASC', - desc: '(SELECT COUNT(*) FROM gear_mod_secondary_options option WHERE option.gear_mod_id=gear_mods.id) DESC,name COLLATE NOCASE ASC', - }, state: { asc: 'active ASC,name COLLATE NOCASE ASC', desc: 'active DESC,name COLLATE NOCASE ASC', @@ -140,7 +129,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { module: 'gearmods', action: 'save', kind: 'gearmod', - fields: ['qualityValues', 'secondaryRanges'], + fields: ['qualityValues'], }, ]), ]); @@ -161,38 +150,29 @@ export const load: PageServerLoad = async ({ cookies, url }) => { pagination.page === 1 ? pendingRows.slice(0, pagination.perPage) : []; const databaseOffset = Math.max(0, pagination.offset - pendingRows.length); const databaseLimit = Math.max(0, pagination.perPage - pendingOnPage.length); - const [mods, qualityRows, secondaryOptions, secondaryValues, stats, renders] = - await Promise.all([ - databaseLimit - ? ( - await database.prepare(`SELECT * FROM gear_mods ${where} + const [mods, qualityRows, stats, renders, secondaryPool] = await Promise.all([ + databaseLimit + ? ( + await database.prepare(`SELECT * FROM gear_mods ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`) - ).all(...args, databaseLimit, databaseOffset) - : [], - (await database.prepare('SELECT * FROM gear_mod_quality_values')).all(), - ( - await database.prepare('SELECT * FROM gear_mod_secondary_options') - ).all(), - ( - await database.prepare( - 'SELECT * FROM gear_mod_secondary_quality_values', - ) - ).all(), - ( - await database.prepare( - 'SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE', - ) - ).all(), - ( - await database.prepare( - "SELECT source_entity_id,id,updated_at FROM managed_images WHERE source_entity_type='gear_mod_render'", - ) - ).all(), - ]); + ).all(...args, databaseLimit, databaseOffset) + : [], + (await database.prepare('SELECT * FROM gear_mod_quality_values')).all(), + ( + await database.prepare( + 'SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE', + ) + ).all(), + ( + await database.prepare( + "SELECT source_entity_id,id,updated_at FROM managed_images WHERE source_entity_type='gear_mod_render'", + ) + ).all(), + getModSecondaryPool(database, 'gear'), + ]); const parsedPending = pendingOnPage.map((item) => ({ ...item, qualityValues: parseJson(String(item.qualityValues), []), - secondaryRanges: parseJson(String(item.secondaryRanges), []), })); return { mods: [ @@ -208,23 +188,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => { primaryLevel20Min: Number(row.primary_level_20_min), primaryLevel20Max: Number(row.primary_level_20_max), })), - secondaryRanges: secondaryOptions - .filter((option) => option.gear_mod_id === mod.id) - .map((option) => ({ - statId: String(option.stat_attribute_id), - values: Object.fromEntries( - secondaryValues - .filter( - (row) => - row.gear_mod_id === mod.id && - row.stat_attribute_id === option.stat_attribute_id, - ) - .map((row) => [ - String(row.quality), - { min: Number(row.min_value), max: Number(row.max_value) }, - ]), - ), - })), renderUrl: (() => { const render = renders.find( (item) => item.source_entity_id === mod.id, @@ -235,6 +198,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { })(), })), ], + secondaryPool, stats: [...pendingStats, ...stats], qualities, summary: { @@ -251,6 +215,60 @@ export const load: PageServerLoad = async ({ cookies, url }) => { }; export const actions: Actions = { + saveSecondaryPool: async ({ request, cookies }) => { + const operator = await requirePermission( + await getSessionUser(cookies), + 'data.manage', + ); + const form = await request.formData(); + if (String(form.get('id') ?? '') !== 'gear-mod-secondary-pool') { + return fail(400, { + gearModSecondaryPoolError: + 'The shared Gear Mod pool target is invalid.', + }); + } + const normalized = normalizeModSecondaryPool( + parseJson(form.get('secondaryRanges'), null), + ); + if (normalized.error) { + return fail(400, { gearModSecondaryPoolError: normalized.error }); + } + const database = await getDatabase(); + const statIds = normalized.pool.map((range) => range.statId); + const validStats = await ( + await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes + WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`) + ).get(...statIds); + if (Number(validStats?.count) !== statIds.length) { + return fail(400, { + gearModSecondaryPoolError: + 'One or more selected Attributes are unavailable.', + }); + } + await database.exec('BEGIN IMMEDIATE'); + try { + const before = await getModSecondaryPool(database, 'gear'); + await replaceModSecondaryPool(database, 'gear', normalized.pool); + await recordIsacAudit({ + actorId: operator.id, + category: 'game_data', + action: 'gear_mod_secondary_pool.updated', + entityId: 'gear-mod-secondary-pool', + before, + after: await getModSecondaryPool(database, 'gear'), + database, + }); + await database.exec('COMMIT'); + } catch (error) { + await database.exec('ROLLBACK'); + throw error; + } + return { + gearModSecondaryPoolSaved: true, + gearModSecondaryPoolMode: 'edit', + resultToken: randomUUID(), + }; + }, save: async ({ request, cookies }) => { const operator = await requirePermission( await getSessionUser(cookies), @@ -275,14 +293,16 @@ export const actions: Actions = { const name = String(form.get('name') ?? '') .normalize('NFKC') .trim(); + if (form.has('secondaryRanges')) { + return fail(400, { + gearModError: + 'Secondary Attributes must be submitted through the shared pool editor.', + }); + } const qualityValues = parseJson( form.get('qualityValues'), [], ); - const secondaryRanges = parseJson( - form.get('secondaryRanges'), - [], - ); if (name.length < 2 || name.length > 100) return fail(400, { gearModError: 'Gear Mod names must be 2–100 characters.', @@ -311,30 +331,6 @@ export const actions: Actions = { 'Configure valid primary Level 1 and Level 20 ranges for all four qualities.', }); } - if ( - secondaryRanges.length < 2 || - secondaryRanges.length > 50 || - new Set(secondaryRanges.map((range) => range.statId)).size !== - secondaryRanges.length || - secondaryRanges.some( - (range) => - !range.statId || - qualities.some((quality) => { - const values = range.values?.[quality]; - return ( - !values || - !Number.isFinite(Number(values.min)) || - !Number.isFinite(Number(values.max)) || - Number(values.max) < Number(values.min) - ); - }), - ) - ) { - return fail(400, { - gearModError: - 'Configure at least two unique possible secondary attributes with valid ranges for every quality.', - }); - } const database = await getDatabase(); if ( await ( @@ -347,15 +343,6 @@ export const actions: Actions = { gearModError: 'That Gear Mod name is already registered.', }); } - const statIds = secondaryRanges.map((range) => range.statId); - const validStats = await ( - await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes - WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`) - ).get(...statIds); - if (Number(validStats?.count) !== statIds.length) - return fail(400, { - gearModError: 'One or more selected attributes are unavailable.', - }); const modId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID(); const before = id ? await snapshot(id) : null; @@ -394,29 +381,6 @@ export const actions: Actions = { row.primaryLevel20Min, row.primaryLevel20Max, ); - await ( - await database.prepare( - 'DELETE FROM gear_mod_secondary_options WHERE gear_mod_id=?', - ) - ).run(modId); - for (const range of secondaryRanges) { - await ( - await database.prepare( - 'INSERT INTO gear_mod_secondary_options(gear_mod_id,stat_attribute_id) VALUES(?,?)', - ) - ).run(modId, range.statId); - for (const quality of qualities) - await ( - await database.prepare(`INSERT INTO gear_mod_secondary_quality_values - (gear_mod_id,stat_attribute_id,quality,min_value,max_value) VALUES(?,?,?,?,?)`) - ).run( - modId, - range.statId, - quality, - range.values[quality].min, - range.values[quality].max, - ); - } await database.exec('COMMIT'); } catch (error) { await database.exec('ROLLBACK'); diff --git a/src/routes/isac/gearmods/+page.svelte b/src/routes/isac/gearmods/+page.svelte index eec00b6..bde9658 100644 --- a/src/routes/isac/gearmods/+page.svelte +++ b/src/routes/isac/gearmods/+page.svelte @@ -5,26 +5,21 @@ import RegistryPagination from '$lib/components/RegistryPagination.svelte'; import RegistryInlineEditor from '$lib/components/RegistryInlineEditor.svelte'; import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte'; - import SearchableAssignment from '$lib/components/SearchableAssignment.svelte'; import SegmentedChoice from '$lib/components/SegmentedChoice.svelte'; import GameDataRenderUploadField from '$lib/components/GameDataRenderUploadField.svelte'; + import ModSecondaryPoolEditor from '$lib/components/ModSecondaryPoolEditor.svelte'; let { data, form } = $props(); const qualityClass = (quality: string) => `quality-${quality.toLowerCase().replace('-', '')}`; const qualityRow = (quality: string) => ({ quality, primaryLevel1Min: 0, primaryLevel1Max: 0, primaryLevel20Min: 0, primaryLevel20Max: 0 }); - const blankSecondary = () => ({ - statId: '', - values: Object.fromEntries(data.qualities.map((quality: string) => [quality, { min: 0, max: 0 }])) - }); const blank = () => ({ id: '', name: '', active: 1, renderUrl: '', - qualityValues: data.qualities.map(qualityRow), - secondaryRanges: [blankSecondary(), blankSecondary()] + qualityValues: data.qualities.map(qualityRow) }); let editing = $state(null); let levelPreview = $state(1); @@ -32,7 +27,6 @@ let handledResultToken = $state(''); let renderResetToken = $state(0); let openedRequestedItem = $state(false); - const stat = (id: string) => data.stats.find((item: any) => item.id === id); const previewRow = $derived(editing?.qualityValues.find((row: any) => row.quality === qualityPreview)); const interpolate = (start: unknown, end: unknown) => Number(start) + (Number(end) - Number(start)) * (levelPreview - 1) / 19; const display = (value: unknown) => Number.isFinite(Number(value)) @@ -44,22 +38,11 @@ qualityValues: data.qualities.map((quality: string) => ({ ...qualityRow(quality), ...(item.qualityValues.find((row: any) => row.quality === quality) ?? {}) - })), - secondaryRanges: item.secondaryRanges.length - ? item.secondaryRanges.map((range: any) => ({ statId: range.statId, values: structuredClone(range.values) })) - : [blankSecondary(), blankSecondary()] + })) }; levelPreview = 1; qualityPreview = 'High-end'; }; - const addSecondary = () => { - if (editing.secondaryRanges.length < 50) editing.secondaryRanges = [...editing.secondaryRanges, blankSecondary()]; - }; - const removeSecondary = (index: number) => { - if (editing.secondaryRanges.length > 2) { - editing.secondaryRanges = editing.secondaryRanges.filter((_: any, itemIndex: number) => itemIndex !== index); - } - }; $effect(() => { if (!openedRequestedItem && data.requestedItemId) { const requested = data.mods.find((item: any) => item.id === data.requestedItemId); @@ -87,6 +70,8 @@ + + -
    @@ -143,34 +127,12 @@ {#if previewRow}
    SELECTED PRIMARY · LEVEL {levelPreview}{display(interpolate(previewRow.primaryLevel1Min,previewRow.primaryLevel20Min))}–{display(interpolate(previewRow.primaryLevel1Max,previewRow.primaryLevel20Max))} - SECONDARY ATTRIBUTE POOL{editing.secondaryRanges.length} OPTIONS + SHARED SECONDARY POOL{data.secondaryPool.length} OPTIONS
    {/if}
    -
    -
    -
    SECONDARY ATTRIBUTE POOLTHE BUILDER SELECTS TWO DISTINCT ATTRIBUTES · EVERY OPTION REQUIRES A RANGE AT EACH QUALITY
    - -
    -
    - {#each editing.secondaryRanges as range,index} -
    - {String(index + 1).padStart(2,'0')} - itemIndex !== index).map((item: any) => item.statId)} bind:value={range.statId}/> - {#each data.qualities as quality} - - {/each} - -
    - {/each} -
    -
    - {#if form?.gearModError}

    {form.gearModError}

    {/if}
    {#if editing.id}{/if}
    @@ -186,12 +148,12 @@
    -
    PRIMARYQUALITYLEVELS
    +
    PRIMARYSECONDARIESQUALITYLEVELS
    {#each data.mods as item}
    {#if item.renderUrl}{/if}
    {item.name}{item.pending ? `PROPOSED BY ${item.proposerName}` : item.id.slice(0,8).toUpperCase()}
    5 OPTIONS - {item.secondaryRanges.length} POSSIBLE · SELECT 2 + {data.secondaryPool.length} SHARED · 0 / 1 / 2 / 2 STANDARD → HIGH-END 01 → 20 {item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'} diff --git a/src/routes/isac/gearmods/secondary-pool-action.test.ts b/src/routes/isac/gearmods/secondary-pool-action.test.ts new file mode 100644 index 0000000..089b710 --- /dev/null +++ b/src/routes/isac/gearmods/secondary-pool-action.test.ts @@ -0,0 +1,134 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + audits: [] as Array>, + database: { + exec: vi.fn(async () => undefined), + prepare: vi.fn(async () => ({ + get: vi.fn(async (...statIds: unknown[]) => ({ count: statIds.length })), + })), + }, + getModSecondaryPool: vi.fn(), + replaceModSecondaryPool: vi.fn(), + requirePermission: vi.fn(async (user) => user), +})); + +vi.mock('$lib/server/auth/session', () => ({ + getSessionUser: vi.fn(async () => ({ id: 'operator-1' })), +})); + +vi.mock('$lib/server/auth/authorization', () => ({ + requireDataView: vi.fn(async (user) => user), + requirePermission: mocks.requirePermission, +})); + +vi.mock('$lib/server/database', () => ({ + getDatabase: vi.fn(async () => mocks.database), +})); + +vi.mock('$lib/server/audit', () => ({ + auditRecord: vi.fn(), + recordIsacAudit: vi.fn(async (event) => { + mocks.audits.push(event); + }), +})); + +vi.mock('$lib/server/data-moderation', () => ({ + getPendingEntities: vi.fn(async () => []), +})); + +vi.mock('$lib/server/managed-images', () => ({ + applyGameDataRender: vi.fn(), + ManagedImageValidationError: class extends Error {}, + managedImageUrl: vi.fn(() => ''), + processManagedImage: vi.fn(), + readGameDataRenderMutation: vi.fn(), +})); + +vi.mock('$lib/server/mod-secondary-pools', () => ({ + getModSecondaryPool: mocks.getModSecondaryPool, + replaceModSecondaryPool: mocks.replaceModSecondaryPool, +})); + +import { actions } from './+page.server'; + +const endpoints = ( + level1Min: number, + level1Max: number, + level20Min: number, + level20Max: number, +) => ({ level1Min, level1Max, level20Min, level20Max }); + +const pool = (suffix: string, offset = 0) => [ + { + statId: `critical-hit-chance-${suffix}`, + values: { + Specialized: endpoints(1 + offset, 2 + offset, 3 + offset, 4 + offset), + Superior: endpoints(2 + offset, 3 + offset, 4 + offset, 5 + offset), + 'High-end': endpoints(3 + offset, 4 + offset, 5 + offset, 6 + offset), + }, + }, + { + statId: `critical-hit-damage-${suffix}`, + values: { + Specialized: endpoints(4 + offset, 5 + offset, 6 + offset, 7 + offset), + Superior: endpoints(5 + offset, 6 + offset, 7 + offset, 8 + offset), + 'High-end': endpoints(6 + offset, 7 + offset, 8 + offset, 9 + offset), + }, + }, +]; + +describe('Gear Mod shared secondary pool action', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.audits.length = 0; + }); + + it('audits the actor, stable pool target, and complete before/after evidence', async () => { + const before = pool('before'); + const after = pool('after', 1); + mocks.getModSecondaryPool + .mockResolvedValueOnce(before) + .mockResolvedValueOnce(after); + + const form = new FormData(); + form.set('id', 'gear-mod-secondary-pool'); + form.set('secondaryRanges', JSON.stringify(after)); + + const result = await actions.saveSecondaryPool?.({ + cookies: {} as never, + request: new Request( + 'http://localhost/isac/gearmods?/saveSecondaryPool', + { method: 'POST', body: form }, + ), + } as never); + + expect(result).toMatchObject({ + gearModSecondaryPoolSaved: true, + gearModSecondaryPoolMode: 'edit', + resultToken: expect.any(String), + }); + expect(mocks.requirePermission).toHaveBeenCalledWith( + { id: 'operator-1' }, + 'data.manage', + ); + expect(mocks.replaceModSecondaryPool).toHaveBeenCalledWith( + mocks.database, + 'gear', + after, + ); + expect(mocks.audits).toEqual([ + { + actorId: 'operator-1', + category: 'game_data', + action: 'gear_mod_secondary_pool.updated', + entityId: 'gear-mod-secondary-pool', + before, + after, + database: mocks.database, + }, + ]); + expect(mocks.database.exec).toHaveBeenNthCalledWith(1, 'BEGIN IMMEDIATE'); + expect(mocks.database.exec).toHaveBeenNthCalledWith(2, 'COMMIT'); + }); +}); diff --git a/src/routes/isac/mechanic-reference/+page.server.ts b/src/routes/isac/mechanic-reference/+page.server.ts index 7d52c29..510f818 100644 --- a/src/routes/isac/mechanic-reference/+page.server.ts +++ b/src/routes/isac/mechanic-reference/+page.server.ts @@ -9,7 +9,7 @@ export const load: PageServerLoad = async ({ cookies }) => { const attributes = await ( await database.prepare(` SELECT id,name,description,unit,type,category,calculation_type,cap_value, - gear_min,gear_max,weapon_min,weapon_max,skill_chip_min,skill_chip_max + gear_min,gear_max,weapon_min,weapon_max FROM stat_attributes WHERE active=1 ORDER BY category COLLATE NOCASE,type COLLATE NOCASE,name COLLATE NOCASE diff --git a/src/routes/isac/mechanic-reference/+page.svelte b/src/routes/isac/mechanic-reference/+page.svelte index 0a1d49a..4f64635 100644 --- a/src/routes/isac/mechanic-reference/+page.svelte +++ b/src/routes/isac/mechanic-reference/+page.svelte @@ -19,8 +19,6 @@ gear_max?: number | null; weapon_min?: number | null; weapon_max?: number | null; - skill_chip_min?: number | null; - skill_chip_max?: number | null; }; let { data } = $props(); @@ -131,7 +129,6 @@ const sourceRanges = (attribute: AttributeVariable) => [ { label: 'GEAR', value: valueRange(attribute.gear_min, attribute.gear_max) }, { label: 'WEAPON', value: valueRange(attribute.weapon_min, attribute.weapon_max) }, - { label: 'SKILL CHIP', value: valueRange(attribute.skill_chip_min, attribute.skill_chip_max) } ].filter((source) => source.value); const superiorFirepower = [ diff --git a/src/routes/isac/protocols/+page.server.ts b/src/routes/isac/protocols/+page.server.ts index 1810b2c..37d29fd 100644 --- a/src/routes/isac/protocols/+page.server.ts +++ b/src/routes/isac/protocols/+page.server.ts @@ -35,27 +35,15 @@ import { saveConditionalRuleInTransaction, } from '$lib/server/conditional-effects'; import { MECHANIC_RECIPE_REGISTRY } from '$lib/calculations/mechanic-recipes'; +import { + getOsProtocolProfileAttributeIds, + type OsProtocolAttributeType, +} from '$lib/server/os-protocol-profiles'; +import type { Database } from '@tursodatabase/database'; const qualities = ['Standard', 'Specialized', 'Superior', 'High-end']; const attributeTypes = ['Firepower', 'Toughness', 'Engineering']; const secondaryRoles = ['Core', 'Damage', 'Health']; -const expectedSecondaryAttributes: Record> = { - Firepower: { - Core: 'Firepower', - Damage: 'All Weapon Damage', - Health: 'Health', - }, - Toughness: { - Core: 'Toughness', - Damage: 'All Weapon Damage', - Health: 'Health', - }, - Engineering: { - Core: 'Engineering', - Damage: 'Skill Damage', - Health: 'Health', - }, -}; const renderAuditMetadata = (render: Record | null | undefined) => render @@ -88,15 +76,15 @@ const descriptionOutcomes = ( Number(left.descriptionSlot) - Number(right.descriptionSlot), ); -async function protocolSnapshot(id: string) { - const database = await getDatabase(); +async function protocolSnapshot(id: string, suppliedDatabase?: Database) { + const database = suppliedDatabase ?? (await getDatabase()); const talent = await ( await database.prepare( 'SELECT * FROM os_protocol_talents WHERE protocol_id=?', ) ).get(id); return { - record: await auditRecord('os_protocols', id), + record: await auditRecord('os_protocols', id, database), icon: await gameDataIconMetadata(database, 'os_protocol', id), render: renderAuditMetadata( await ( @@ -129,8 +117,8 @@ async function protocolSnapshot(id: string) { }; } -async function profileSnapshot(id: string) { - const database = await getDatabase(); +async function profileSnapshot(id: string, suppliedDatabase?: Database) { + const database = suppliedDatabase ?? (await getDatabase()); return { record: (await ( @@ -292,6 +280,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { 'attributeType', 'primaryStatId', 'primaryBaseValue', + 'hasTalent', 'talentName', 'descriptionTemplate', 'conditionalRule', @@ -341,17 +330,20 @@ export const load: PageServerLoad = async ({ cookies, url }) => { profile.quality === protocol.quality && profile.primary_attribute_type === protocol.attributeType, ) ?? null, - talent: { - name: protocol.talentName, - description_template: protocol.descriptionTemplate, - conditionalRule: (() => { - try { - return JSON.parse(String(protocol.conditionalRule ?? 'null')); - } catch { - return null; - } - })(), - }, + talent: + protocol.hasTalent === '1' + ? { + name: protocol.talentName, + description_template: protocol.descriptionTemplate, + conditionalRule: (() => { + try { + return JSON.parse(String(protocol.conditionalRule ?? 'null')); + } catch { + return null; + } + })(), + } + : null, })); const configuredProtocols = await Promise.all( protocols.map(async (protocol) => { @@ -450,27 +442,26 @@ export const actions: Actions = { profileError: 'Choose a valid quality and attribute type.', }); const database = await getDatabase(); - const expected = expectedSecondaryAttributes[attributeType]; - const statRows = await ( - await database.prepare( - `SELECT id,name FROM stat_attributes WHERE active=1 AND name IN (?,?,?) COLLATE NOCASE`, - ) - ).all(expected.Core, expected.Damage, expected.Health); - const statByName = new Map( - statRows.map((stat) => [ - String(stat.name).toLowerCase(), - String(stat.id), - ]), + const attributeIds = await getOsProtocolProfileAttributeIds( + database, + attributeType as OsProtocolAttributeType, ); - if (statRows.length !== 3) + if (Object.values(attributeIds).some((id) => !id)) return fail(400, { - profileError: `Create active stats named ${Object.values(expected).join(', ')} before configuring this profile.`, + profileError: + 'Create the required active core, damage, and Health Attributes before configuring this profile.', }); - const effects = secondaryRoles.map((role) => ({ - role, - statId: statByName.get(expected[role].toLowerCase()) ?? '', - value: Number(form.get(`value${role}`)), - })); + const effects = secondaryRoles.map((role) => { + const rawValue = form.get(`value${role}`); + return { + role, + statId: attributeIds[role as keyof typeof attributeIds] ?? '', + value: + typeof rawValue === 'string' && rawValue.trim() + ? Number(rawValue) + : Number.NaN, + }; + }); if ( effects.some( (effect) => @@ -481,47 +472,58 @@ export const actions: Actions = { profileError: 'Every shared secondary attribute requires a non-negative value.', }); - const existing = await ( - await database.prepare( - 'SELECT id FROM os_protocol_secondary_profiles WHERE quality=? AND primary_attribute_type=?', - ) - ).get(quality, attributeType); - const profileId = String(existing?.id ?? randomUUID()), - now = new Date().toISOString(); - const before = existing ? await profileSnapshot(profileId) : null; - if (existing) - await ( - await database.prepare( - 'UPDATE os_protocol_secondary_profiles SET updated_at=? WHERE id=?', + const saveProfile = database.transactionAsync(async (transaction) => { + const tx = transaction as unknown as Database; + const existing = await ( + await tx.prepare( + 'SELECT id FROM os_protocol_secondary_profiles WHERE quality=? AND primary_attribute_type=?', ) - ).run(now, profileId); - else + ).get(quality, attributeType); + const profileId = String(existing?.id ?? randomUUID()); + const now = new Date().toISOString(); + const before = existing ? await profileSnapshot(profileId, tx) : null; + if (existing) + await ( + await tx.prepare( + 'UPDATE os_protocol_secondary_profiles SET updated_at=? WHERE id=?', + ) + ).run(now, profileId); + else + await ( + await tx.prepare( + 'INSERT INTO os_protocol_secondary_profiles(id,quality,primary_attribute_type,created_at,updated_at) VALUES(?,?,?,?,?)', + ) + ).run(profileId, quality, attributeType, now, now); await ( - await database.prepare( - 'INSERT INTO os_protocol_secondary_profiles(id,quality,primary_attribute_type,created_at,updated_at) VALUES(?,?,?,?,?)', + await tx.prepare( + 'DELETE FROM os_protocol_secondary_effects WHERE profile_id=?', ) - ).run(profileId, quality, attributeType, now, now); - await ( - await database.prepare( - 'DELETE FROM os_protocol_secondary_effects WHERE profile_id=?', - ) - ).run(profileId); - for (const effect of effects) - await ( - await database.prepare( - 'INSERT INTO os_protocol_secondary_effects(id,profile_id,effect_role,stat_attribute_id,value) VALUES(?,?,?,?,?)', - ) - ).run(randomUUID(), profileId, effect.role, effect.statId, effect.value); - await recordIsacAudit({ - actorId: operator.id, - category: 'game_data', - action: existing - ? 'os_protocol_profile.updated' - : 'os_protocol_profile.created', - entityId: profileId, - before, - after: await profileSnapshot(profileId), + ).run(profileId); + for (const effect of effects) + await ( + await tx.prepare( + 'INSERT INTO os_protocol_secondary_effects(id,profile_id,effect_role,stat_attribute_id,value) VALUES(?,?,?,?,?)', + ) + ).run( + randomUUID(), + profileId, + effect.role, + effect.statId, + effect.value, + ); + await recordIsacAudit({ + actorId: operator.id, + category: 'game_data', + action: existing + ? 'os_protocol_profile.updated' + : 'os_protocol_profile.created', + entityId: profileId, + before, + after: await profileSnapshot(profileId, tx), + database: tx, + }); }); + await saveProfile.immediate(); return { profileSaved: true, resultToken: randomUUID() }; }, save: async ({ request, cookies }) => { @@ -559,7 +561,12 @@ export const actions: Actions = { const quality = String(form.get('quality') ?? ''); const attributeType = String(form.get('attributeType') ?? ''); const primaryStatId = String(form.get('primaryStatId') ?? ''); - const primaryBaseValue = Number(form.get('primaryBaseValue')); + const primaryBaseValueRaw = form.get('primaryBaseValue'); + const primaryBaseValue = + typeof primaryBaseValueRaw === 'string' && primaryBaseValueRaw.trim() + ? Number(primaryBaseValueRaw) + : Number.NaN; + const hasTalent = form.get('hasTalent') === '1'; const talentName = String(form.get('talentName') ?? '') .normalize('NFKC') .trim(); @@ -567,22 +574,27 @@ export const actions: Actions = { .normalize('NFKC') .trim(); const database = await getDatabase(); - let conditionalRule; + let conditionalRule: ReturnType | null = + null; try { - const conditionalRuleRaw = form.get('conditionalRule'); - const activeStats = await ( - await database.prepare('SELECT id FROM stat_attributes WHERE active=1') - ).all(); - conditionalRule = parseConditionalRulePayload( - conditionalRuleRaw, - [{ key: 'base', variantKey: 'base' }], - { - allowedAttributeIds: new Set( - activeStats.map((row) => String(row.id)), - ), - allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY)), - }, - ); + if (hasTalent) { + const conditionalRuleRaw = form.get('conditionalRule'); + const activeStats = await ( + await database.prepare( + 'SELECT id FROM stat_attributes WHERE active=1', + ) + ).all(); + conditionalRule = parseConditionalRulePayload( + conditionalRuleRaw, + [{ key: 'base', variantKey: 'base' }], + { + allowedAttributeIds: new Set( + activeStats.map((row) => String(row.id)), + ), + allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY)), + }, + ); + } } catch (error) { return fail(400, { protocolError: @@ -608,29 +620,39 @@ export const actions: Actions = { protocolError: 'Choose the protocol’s unique primary stat and a non-negative level-1 base value.', }); - const outcomes = mechanicOutcomes(conditionalRule); - const numberedOutcomes = descriptionOutcomes(conditionalRule); + const outcomes = conditionalRule ? mechanicOutcomes(conditionalRule) : []; + const numberedOutcomes = conditionalRule + ? descriptionOutcomes(conditionalRule) + : []; const durationSeconds = - conditionalRule.pools.find((pool) => pool.expiresAfterSeconds) + conditionalRule?.pools.find((pool) => pool.expiresAfterSeconds) ?.expiresAfterSeconds ?? null; - const maxStacks = conditionalRule.pools.length + const maxStacks = conditionalRule?.pools.length ? Math.max(...conditionalRule.pools.map((pool) => pool.max ?? 1)) : null; + const requiresCompleteMechanic = + hasTalent && conditionalRule?.simulationReadiness !== 'captured_only'; if ( - talentName.length < 2 || - talentName.length > 80 || - !descriptionTemplate || - !conditionalRule.clauses.length || - !outcomes.length + hasTalent && + (talentName.length < 2 || talentName.length > 80 || !descriptionTemplate) ) return fail(400, { protocolError: - 'The unique talent requires a name, description template, mechanic branch, and at least one outcome.', + 'The assigned Talent requires a name and description template.', + }); + if ( + requiresCompleteMechanic && + (!conditionalRule?.clauses.length || !outcomes.length) + ) + return fail(400, { + protocolError: + 'Add a mechanic branch and outcome, or mark the Talent CAPTURE ONLY.', }); const placeholders = [...descriptionTemplate.matchAll(/\{(\d+)\}/g)].map( (match) => Number(match[1]), ); if ( + requiresCompleteMechanic && placeholders.some( (index) => !numberedOutcomes.some( @@ -642,23 +664,49 @@ export const actions: Actions = { protocolError: 'Description placeholders must reference configured numbered outcomes.', }); - if (descriptionTemplate.includes('{duration}') && durationSeconds === null) + if ( + requiresCompleteMechanic && + descriptionTemplate.includes('{duration}') && + durationSeconds === null + ) return fail(400, { protocolError: 'The description uses {duration}, but no expiring state pool is defined.', }); - if (descriptionTemplate.includes('{max_stacks}') && !maxStacks) + if ( + requiresCompleteMechanic && + descriptionTemplate.includes('{max_stacks}') && + !maxStacks + ) return fail(400, { protocolError: 'The description uses {max_stacks}, but no state pool maximum is defined.', }); - if ( - !(await ( + const profileAttributeIds = await getOsProtocolProfileAttributeIds( + database, + attributeType as OsProtocolAttributeType, + ); + const completeProfile = + Object.values(profileAttributeIds).every(Boolean) && + (await ( await database.prepare( - 'SELECT id FROM os_protocol_secondary_profiles WHERE quality=? AND primary_attribute_type=?', + `SELECT profile.id FROM os_protocol_secondary_profiles profile + JOIN os_protocol_secondary_effects effect ON effect.profile_id=profile.id + WHERE profile.quality=? AND profile.primary_attribute_type=? + GROUP BY profile.id + HAVING COUNT(effect.id)=3 + AND SUM(CASE WHEN effect.effect_role='Core' AND effect.stat_attribute_id=? THEN 1 ELSE 0 END)=1 + AND SUM(CASE WHEN effect.effect_role='Damage' AND effect.stat_attribute_id=? THEN 1 ELSE 0 END)=1 + AND SUM(CASE WHEN effect.effect_role='Health' AND effect.stat_attribute_id=? THEN 1 ELSE 0 END)=1`, ) - ).get(quality, attributeType)) - ) + ).get( + quality, + attributeType, + profileAttributeIds.Core, + profileAttributeIds.Damage, + profileAttributeIds.Health, + )); + if (!completeProfile) return fail(400, { protocolError: 'Configure the shared secondary profile for this quality and attribute type first.', @@ -679,11 +727,21 @@ export const actions: Actions = { ).get(id) : null; if ( - await ( + existingTalent && + !hasTalent && + form.get('confirmTalentRemoval') !== 'REMOVE' + ) + return fail(400, { + protocolError: + 'Confirm removal of the existing Talent and mechanic program.', + }); + if ( + hasTalent && + (await ( await database.prepare( 'SELECT id FROM os_protocol_talents WHERE name=? COLLATE NOCASE AND id<>?', ) - ).get(talentName, existingTalent?.id ?? 'new') + ).get(talentName, existingTalent?.id ?? 'new')) ) return fail(409, { protocolError: 'That protocol talent name is already in use.', @@ -701,71 +759,83 @@ export const actions: Actions = { return fail(400, { protocolError: 'One or more selected attributes are unavailable.', }); - const protocolId = id || randomUUID(), + const protocolId = + id || String(form.get('_proposedEntityId') ?? '') || randomUUID(), talentId = String(existingTalent?.id ?? randomUUID()), now = new Date().toISOString(); - const before = id ? await protocolSnapshot(id) : null; - if (id && !before?.record) - return fail(404, { protocolError: 'OS Protocol not found.' }); - await database.exec('BEGIN'); - try { + const saveProtocol = database.transactionAsync(async (transaction) => { + const tx = transaction as unknown as Database; + const before = id ? await protocolSnapshot(id, tx) : null; + if (id && !before?.record) + return fail(404, { protocolError: 'OS Protocol not found.' }); if (id) { await ( - await database.prepare( + await tx.prepare( 'UPDATE os_protocols SET name=?,quality=?,primary_attribute_type=?,active=1,updated_at=? WHERE id=?', ) ).run(name, quality, attributeType, now, id); } else await ( - await database.prepare( + await tx.prepare( 'INSERT INTO os_protocols(id,name,quality,primary_attribute_type,created_at,updated_at) VALUES(?,?,?,?,?,?)', ) ).run(protocolId, name, quality, attributeType, now, now); await ( - await database.prepare(`INSERT INTO os_protocol_primary_effects(id,protocol_id,stat_attribute_id,base_value) VALUES(?,?,?,?) + await tx.prepare(`INSERT INTO os_protocol_primary_effects(id,protocol_id,stat_attribute_id,base_value) VALUES(?,?,?,?) ON CONFLICT(protocol_id) DO UPDATE SET stat_attribute_id=excluded.stat_attribute_id,base_value=excluded.base_value`) ).run(randomUUID(), protocolId, primaryStatId, primaryBaseValue); - await ( - await database.prepare(`INSERT INTO os_protocol_talents(id,protocol_id,name,description_template,created_at,updated_at) VALUES(?,?,?,?,?,?) - ON CONFLICT(protocol_id) DO UPDATE SET name=excluded.name,description_template=excluded.description_template,updated_at=excluded.updated_at`) - ).run(talentId, protocolId, talentName, descriptionTemplate, now, now); - await ( - await database.prepare( - 'DELETE FROM os_protocol_talent_effects WHERE talent_id=?', - ) - ).run(talentId); - let compatibilitySortOrder = 0; - for (const outcome of numberedOutcomes) { - if (outcome.kind !== 'attribute_modifier' || !outcome.attributeId) - continue; - compatibilitySortOrder += 1; + if (hasTalent && conditionalRule) { await ( - await database.prepare( - 'INSERT INTO os_protocol_talent_effects(id,talent_id,stat_attribute_id,base_value,sort_order) VALUES(?,?,?,?,?)', + await tx.prepare(`INSERT INTO os_protocol_talents(id,protocol_id,name,description_template,created_at,updated_at) VALUES(?,?,?,?,?,?) + ON CONFLICT(protocol_id) DO UPDATE SET name=excluded.name,description_template=excluded.description_template,updated_at=excluded.updated_at`) + ).run(talentId, protocolId, talentName, descriptionTemplate, now, now); + await ( + await tx.prepare( + 'DELETE FROM os_protocol_talent_effects WHERE talent_id=?', ) - ).run( - randomUUID(), + ).run(talentId); + let compatibilitySortOrder = 0; + for (const outcome of numberedOutcomes) { + if (outcome.kind !== 'attribute_modifier' || !outcome.attributeId) + continue; + compatibilitySortOrder += 1; + await ( + await tx.prepare( + 'INSERT INTO os_protocol_talent_effects(id,talent_id,stat_attribute_id,base_value,sort_order) VALUES(?,?,?,?,?)', + ) + ).run( + randomUUID(), + talentId, + outcome.attributeId, + outcome.variants.base.value, + compatibilitySortOrder, + ); + } + await saveConditionalRuleInTransaction( + tx, + 'os_protocol_talent', talentId, - outcome.attributeId, - outcome.variants.base.value, - compatibilitySortOrder, + conditionalRule, ); + } else if (existingTalent) { + await ( + await tx.prepare( + "DELETE FROM conditional_effect_rules WHERE source_type='os_protocol_talent' AND source_id=?", + ) + ).run(String(existingTalent.id)); + await ( + await tx.prepare('DELETE FROM os_protocol_talents WHERE id=?') + ).run(String(existingTalent.id)); } - await saveConditionalRuleInTransaction( - database, - 'os_protocol_talent', - talentId, - conditionalRule, - ); await applyIconMutation( - database, + tx, 'os_protocol', protocolId, iconMutation, operator.id, ); await applyGameDataRender( - database, + tx, 'os_protocol', protocolId, name, @@ -778,13 +848,13 @@ export const actions: Actions = { action: id ? 'os_protocol.updated' : 'os_protocol.created', entityId: protocolId, before, - after: await protocolSnapshot(protocolId), + after: await protocolSnapshot(protocolId, tx), + database: tx, }); - await database.exec('COMMIT'); - } catch (error) { - await database.exec('ROLLBACK'); - throw error; - } + return null; + }); + const transactionResult = await saveProtocol.immediate(); + if (transactionResult) return transactionResult; return { protocolSaved: true, protocolMode: id ? 'edit' : 'add', diff --git a/src/routes/isac/protocols/+page.svelte b/src/routes/isac/protocols/+page.svelte index f0d8d0f..eb5df67 100644 --- a/src/routes/isac/protocols/+page.svelte +++ b/src/routes/isac/protocols/+page.svelte @@ -29,15 +29,16 @@ simulationReadiness: 'captured_only', scenarioInputs: [], pools: [], - clauses: [{key:'primary',label:'Primary',event:'tick',if:{kind:'true'},reducer:{kind:'none'},mutations:[],outcomes:[{key:'effect-1',label:'Effect 1',phase:'while_active',kind:'attribute_modifier',scope:'self',attributeId:'',basis:'flat',perStack:true,descriptionSlot:1,variants:{base:{value:0}}}]}] + clauses: [] }); const blankTalent = () => ({ id: '', name: '', description_template: '', conditionalRule: blankRule() }); - const blank = () => ({ id: '', name: '', iconUrl: '', renderUrl: '', quality: 'Standard', primary_attribute_type: 'Firepower', active: 1, primaryEffect: { stat_attribute_id: '', base_value: '' }, talent: blankTalent() }); + const blank = () => ({ id: '', name: '', iconUrl: '', renderUrl: '', quality: 'Standard', primary_attribute_type: 'Firepower', active: 1, primaryEffect: { stat_attribute_id: '', base_value: '' }, hasTalent: false, talentOpen: false, talent: blankTalent() }); let editing = $state(null); let profileEditing = $state(null); let talentUpgrade = $state(0); let protocolLevel = $state(1); let handledResultToken = $state(''); + let handledErrorForm = $state(null); let iconResetToken = $state(0); let renderResetToken = $state(0); let openedRequestedItem = $state(false); @@ -86,6 +87,8 @@ editing = { ...protocol, primaryEffect: protocol.primaryEffect ? { ...protocol.primaryEffect } : { stat_attribute_id: '', base_value: '' }, + hasTalent: Boolean(protocol.talent), + talentOpen: false, talent: protocol.talent ? { ...protocol.talent, conditionalRule: protocol.talent.conditionalRule ?? blankRule() @@ -115,6 +118,10 @@ talentUpgrade = 0; protocolLevel = 1; } + if (form?.protocolError && form !== handledErrorForm) { + handledErrorForm = form; + if (editing?.hasTalent) editing.talentOpen = true; + } }); @@ -162,6 +169,7 @@

    {editing.id ? 'MODIFY PROTOCOL' : 'NEW PROTOCOL'}

    {editing.id ? editing.name : 'OS PROTOCOL DEFINITION'}

    +
    @@ -178,21 +186,31 @@
    PRIMARY STAT
    LEVEL {protocolLevel} PREVIEW{format(levelValue(editing.primaryEffect.base_value, protocolLevel), editing.primaryEffect.stat_attribute_id)}LEVEL 50 · {format(levelValue(editing.primaryEffect.base_value, 50), editing.primaryEffect.stat_attribute_id)}
    -
    UNIQUE PROTOCOL TALENTOWNED EXCLUSIVELY BY THIS PROTOCOL · FIVE LINEAR UPGRADES FOR MARKED VALUES
    {editing.talent.id ? 'LINKED RECORD' : 'NEW RECORD'}
    -
    -
    {#each [1, 2, 3, 4, 5] as upgrade}{/each}
    UPGRADE {talentUpgrade} / 5 · {talentUpgrade === 5 ? '2× SCALABLE VALUES' : `${Math.round((1 + talentUpgrade / 5) * 100)}% SCALABLE VALUES`}

    {talentPreview(talentUpgrade) || 'Talent description preview'}

    - +
    UNIQUE PROTOCOL TALENTOPTIONAL OWNED TALENT · FIVE LINEAR UPGRADES FOR MARKED VALUES
    { editing.hasTalent = values[0] === '1'; if (editing.hasTalent) editing.talentOpen = true; }}/>
    + {#if editing.hasTalent} +
    + {editing.talent.name || 'TALENT WORKSPACE'}{previewOutcomes().length} PROTOCOL OUTCOME{previewOutcomes().length === 1 ? '' : 'S'} · {editing.talent.conditionalRule.simulationReadiness === 'captured_only' ? 'CAPTURE ONLY' : 'MODELED'}{editing.talentOpen ? 'COLLAPSE' : 'EXPAND'} +
    +
    +
    {#each [1, 2, 3, 4, 5] as upgrade}{/each}
    UPGRADE {talentUpgrade} / 5 · {talentUpgrade === 5 ? '2× SCALABLE VALUES' : `${Math.round((1 + talentUpgrade / 5) * 100)}% SCALABLE VALUES`}

    {talentPreview(talentUpgrade) || 'Talent description preview'}

    + +
    +
    + {:else} +

    This OS Protocol has no Talent. Its primary and inherited secondary Attributes remain available.

    + {#if editing.talent.id}{/if} + {/if}
    {#if form?.protocolError}

    {form.protocolError}

    {/if}
    {#if editing.id}{/if}
    @@ -206,7 +224,7 @@
    {#each data.protocols as protocol} -
    {protocol.name}{protocol.pending ? `PROPOSED BY ${protocol.proposerName}` : 'LEVEL 1–50 · PRIMARY 2× MAX'}
    {protocol.quality}{protocol.primary_attribute_type}{protocol.primaryEffect ? `${stat(protocol.primaryEffect.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(protocol.primaryEffect.base_value, protocol.primaryEffect.stat_attribute_id)} → ${format(levelValue(protocol.primaryEffect.base_value, 50), protocol.primaryEffect.stat_attribute_id)}` : 'NOT CONFIGURED'}
    {protocol.talent?.name ?? 'NOT CONFIGURED'}◆◆◆◆◆ {protocol.talent?.conditionalRule?.clauses?.length || protocol.pending ? 'MODELED · ' : 'CONFIG REQUIRED · '}5 UPGRADES
    {protocol.pending ? 'PENDING' : Number(protocol.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}{#if protocol.pending}{:else}{/if}
    +
    {protocol.name}{protocol.pending ? `PROPOSED BY ${protocol.proposerName}` : 'LEVEL 1–50 · PRIMARY 2× MAX'}
    {protocol.quality}{protocol.primary_attribute_type}{protocol.primaryEffect ? `${stat(protocol.primaryEffect.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(protocol.primaryEffect.base_value, protocol.primaryEffect.stat_attribute_id)} → ${format(levelValue(protocol.primaryEffect.base_value, 50), protocol.primaryEffect.stat_attribute_id)}` : 'NOT CONFIGURED'}
    {protocol.talent?.name ?? 'NOT ASSIGNED'}{#if protocol.talent}◆◆◆◆◆ {protocol.talent.conditionalRule?.clauses?.length || protocol.pending ? 'MODELED · ' : 'CAPTURE ONLY · '}5 UPGRADES{:else}OPTIONAL{/if}
    {protocol.pending ? 'PENDING' : Number(protocol.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}{#if protocol.pending}{:else}{/if}
    {#if editing?.id === protocol.id}{@render protocolEditor()}{/if} {:else}
    NO OS PROTOCOLS REGISTEREDConfigure shared secondary profiles, then add the first protocol.
    {/each}
    diff --git a/src/routes/isac/protocols/profile-action.test.ts b/src/routes/isac/protocols/profile-action.test.ts new file mode 100644 index 0000000..bd237bf --- /dev/null +++ b/src/routes/isac/protocols/profile-action.test.ts @@ -0,0 +1,191 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => { + const transaction = { prepare: vi.fn() }; + const database = { + prepare: vi.fn(), + transactionAsync: vi.fn(), + }; + return { + transaction, + database, + profileExists: false, + effects: [] as Array>, + getAttributeIds: vi.fn(), + recordIsacAudit: vi.fn(), + }; +}); + +vi.mock('$lib/server/auth/session', () => ({ + getSessionUser: vi.fn(async () => ({ id: 'operator-1' })), +})); + +vi.mock('$lib/server/auth/authorization', () => ({ + requireDataView: vi.fn(async (user) => user), + requirePermission: vi.fn(async (user) => user), +})); + +vi.mock('$lib/server/database', () => ({ + getDatabase: vi.fn(async () => mocks.database), +})); + +vi.mock('$lib/server/audit', () => ({ + auditRecord: vi.fn(), + recordIsacAudit: mocks.recordIsacAudit, +})); + +vi.mock('$lib/server/data-moderation', () => ({ + getPendingEntities: vi.fn(async () => []), +})); + +vi.mock('$lib/server/os-protocol-profiles', () => ({ + getOsProtocolProfileAttributeIds: mocks.getAttributeIds, +})); + +vi.mock('$lib/server/game-data-icons', () => ({ + applyIconMutation: vi.fn(), + gameDataIconMetadata: vi.fn(), + gameDataIconUrl: vi.fn(() => ''), + GameDataIconValidationError: class extends Error {}, + readIconMutation: vi.fn(), +})); + +vi.mock('$lib/server/managed-images', () => ({ + applyGameDataRender: vi.fn(), + ManagedImageValidationError: class extends Error {}, + managedImageUrl: vi.fn(() => ''), + processManagedImage: vi.fn(), + readGameDataRenderMutation: vi.fn(), +})); + +vi.mock('$lib/server/conditional-effects', () => ({ + conditionalRuleSnapshot: vi.fn(), + loadConditionalRuleDraft: vi.fn(), + parseConditionalRulePayload: vi.fn(), + saveConditionalRuleInTransaction: vi.fn(), +})); + +import { actions } from './+page.server'; + +const request = (includeValues = true) => { + const form = new FormData(); + form.set('quality', 'Standard'); + form.set('attributeType', 'Firepower'); + if (includeValues) { + form.set('valueCore', '10'); + form.set('valueDamage', '20'); + form.set('valueHealth', '30'); + } + return new Request('http://localhost/isac/protocols?/saveProfile', { + method: 'POST', + body: form, + }); +}; + +describe('OS Protocol shared profile action', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.profileExists = false; + mocks.effects = []; + mocks.getAttributeIds.mockResolvedValue({ + Core: 'firepower-flat', + Damage: 'weapon-damage-flat', + Health: 'health-flat', + }); + mocks.transaction.prepare.mockImplementation(async (sql: string) => ({ + get: vi.fn(async () => { + if (sql.includes('WHERE quality=?')) + return mocks.profileExists ? { id: 'profile-1' } : null; + if (sql.includes('WHERE id=?')) + return mocks.profileExists + ? { + id: 'profile-1', + quality: 'Standard', + primary_attribute_type: 'Firepower', + } + : null; + return null; + }), + all: vi.fn(async () => + sql.includes('os_protocol_secondary_effects') ? mocks.effects : [], + ), + run: vi.fn(async (...args: unknown[]) => { + if (sql.includes('INSERT INTO os_protocol_secondary_profiles')) + mocks.profileExists = true; + if (sql.includes('DELETE FROM os_protocol_secondary_effects')) + mocks.effects = []; + if (sql.includes('INSERT INTO os_protocol_secondary_effects')) + mocks.effects.push({ + profile_id: 'profile-1', + effect_role: args[2], + stat_attribute_id: args[3], + value: args[4], + }); + }), + })); + mocks.database.transactionAsync.mockImplementation( + (callback: (transaction: typeof mocks.transaction) => Promise) => { + const run = vi.fn(() => callback(mocks.transaction)); + return Object.assign(run, { immediate: run }); + }, + ); + }); + + it('creates and audits all three profile roles in one owned transaction', async () => { + const result = await actions.saveProfile?.({ + cookies: {} as never, + request: request(), + } as never); + + expect(result).toMatchObject({ + profileSaved: true, + resultToken: expect.any(String), + }); + expect(mocks.database.transactionAsync).toHaveBeenCalledOnce(); + expect(mocks.effects).toHaveLength(3); + expect(mocks.recordIsacAudit).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: 'operator-1', + category: 'game_data', + action: 'os_protocol_profile.created', + before: null, + after: expect.objectContaining({ + effects: expect.arrayContaining([ + expect.objectContaining({ effect_role: 'Core', value: 10 }), + expect.objectContaining({ effect_role: 'Damage', value: 20 }), + expect.objectContaining({ effect_role: 'Health', value: 30 }), + ]), + }), + database: mocks.transaction, + }), + ); + }); + + it('does not start persistence when a required Attribute is missing', async () => { + mocks.getAttributeIds.mockResolvedValue({ + Core: 'firepower-flat', + Damage: null, + Health: 'health-flat', + }); + + const result = await actions.saveProfile?.({ + cookies: {} as never, + request: request(), + } as never); + + expect(result).toMatchObject({ status: 400 }); + expect(mocks.database.transactionAsync).not.toHaveBeenCalled(); + expect(mocks.recordIsacAudit).not.toHaveBeenCalled(); + }); + + it('rejects missing numeric values instead of coercing them to zero', async () => { + const result = await actions.saveProfile?.({ + cookies: {} as never, + request: request(false), + } as never); + + expect(result).toMatchObject({ status: 400 }); + expect(mocks.database.transactionAsync).not.toHaveBeenCalled(); + expect(mocks.recordIsacAudit).not.toHaveBeenCalled(); + }); +}); diff --git a/src/routes/isac/protocols/save-actions.integration.test.ts b/src/routes/isac/protocols/save-actions.integration.test.ts new file mode 100644 index 0000000..5bc475b --- /dev/null +++ b/src/routes/isac/protocols/save-actions.integration.test.ts @@ -0,0 +1,231 @@ +import { connect, type Database } from '@tursodatabase/database'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + getDatabase: vi.fn(), +})); + +vi.mock('$lib/server/auth/session', () => ({ + getSessionUser: vi.fn(async () => ({ id: 'operator-1' })), +})); + +vi.mock('$lib/server/auth/authorization', () => ({ + requireDataView: vi.fn(async (user) => user), + requirePermission: vi.fn(async (user) => user), +})); + +vi.mock('$lib/server/database', () => ({ + getDatabase: mocks.getDatabase, +})); + +vi.mock('$lib/server/data-moderation', () => ({ + getPendingEntities: vi.fn(async () => []), +})); + +import { actions } from './+page.server'; + +let database: Database; + +const profileRequest = () => { + const form = new FormData(); + form.set('quality', 'Standard'); + form.set('attributeType', 'Firepower'); + form.set('valueCore', '10'); + form.set('valueDamage', '20'); + form.set('valueHealth', '30'); + return new Request('http://localhost/isac/protocols?/saveProfile', { + method: 'POST', + body: form, + }); +}; + +const protocolRequest = () => { + const form = new FormData(); + form.set('id', ''); + form.set('name', 'Integration Protocol'); + form.set('quality', 'Standard'); + form.set('attributeType', 'Firepower'); + form.set('primaryStatId', 'weapon-damage-flat'); + form.set('primaryBaseValue', '5'); + form.set('hasTalent', '0'); + return new Request('http://localhost/isac/protocols?/save', { + method: 'POST', + body: form, + }); +}; + +describe('OS Protocol save actions with embedded Turso', () => { + beforeEach(async () => { + database = await connect(':memory:'); + mocks.getDatabase.mockResolvedValue(database); + await database.exec(` + CREATE TABLE stat_attributes ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + unit TEXT NOT NULL, + active INTEGER NOT NULL + ); + CREATE TABLE os_protocols ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + quality TEXT NOT NULL, + primary_attribute_type TEXT NOT NULL, + active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE UNIQUE INDEX os_protocols_name_nocase_idx ON os_protocols(name COLLATE NOCASE); + CREATE TABLE os_protocol_primary_effects ( + id TEXT PRIMARY KEY, + protocol_id TEXT NOT NULL UNIQUE, + stat_attribute_id TEXT NOT NULL, + base_value REAL NOT NULL + ); + CREATE TABLE os_protocol_secondary_profiles ( + id TEXT PRIMARY KEY, + quality TEXT NOT NULL, + primary_attribute_type TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(quality, primary_attribute_type) + ); + CREATE TABLE os_protocol_secondary_effects ( + id TEXT PRIMARY KEY, + profile_id TEXT NOT NULL, + effect_role TEXT NOT NULL, + stat_attribute_id TEXT NOT NULL, + value REAL NOT NULL, + UNIQUE(profile_id, effect_role), + UNIQUE(profile_id, stat_attribute_id) + ); + CREATE TABLE os_protocol_talents ( + id TEXT PRIMARY KEY, + protocol_id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description_template TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE os_protocol_talent_effects ( + id TEXT PRIMARY KEY, + talent_id TEXT NOT NULL, + stat_attribute_id TEXT NOT NULL, + base_value REAL NOT NULL, + sort_order INTEGER NOT NULL + ); + CREATE TABLE game_data_icons ( + entity_type TEXT NOT NULL, + entity_id TEXT NOT NULL, + storage_key TEXT, + byte_size INTEGER, + updated_at TEXT + ); + CREATE TABLE managed_images ( + id TEXT PRIMARY KEY, + category TEXT, + tags_json TEXT, + source_entity_type TEXT, + source_entity_id TEXT + ); + CREATE TABLE audit_log ( + id TEXT PRIMARY KEY, + actor_id TEXT NOT NULL, + entity_type TEXT NOT NULL, + entity_id TEXT NOT NULL, + action TEXT NOT NULL, + before_json TEXT, + after_json TEXT, + created_at TEXT NOT NULL + ); + `); + for (const [id, name, unit] of [ + ['firepower-flat', 'Firepower', ''], + ['firepower-percent', 'Firepower', '%'], + ['weapon-damage-flat', 'Weapon Damage', ''], + ['weapon-damage-percent', 'Weapon Damage', '%'], + ['health-flat', 'Health', ''], + ]) { + await ( + await database.prepare( + 'INSERT INTO stat_attributes(id,name,unit,active) VALUES(?,?,?,1)', + ) + ).run(id, name, unit); + } + }); + + afterEach(async () => { + await database.close(); + }); + + it('saves a complete shared profile and then a no-Talent Protocol', async () => { + const profileResult = await actions.saveProfile?.({ + cookies: {} as never, + request: profileRequest(), + } as never); + + expect(profileResult).toMatchObject({ profileSaved: true }); + const profile = await ( + await database.prepare( + "SELECT id FROM os_protocol_secondary_profiles WHERE quality='Standard' AND primary_attribute_type='Firepower'", + ) + ).get(); + expect(profile).toBeTruthy(); + expect( + await ( + await database.prepare( + 'SELECT effect_role,stat_attribute_id,value FROM os_protocol_secondary_effects WHERE profile_id=? ORDER BY effect_role', + ) + ).all(profile?.id), + ).toEqual([ + { + effect_role: 'Core', + stat_attribute_id: 'firepower-flat', + value: 10, + }, + { + effect_role: 'Damage', + stat_attribute_id: 'weapon-damage-flat', + value: 20, + }, + { + effect_role: 'Health', + stat_attribute_id: 'health-flat', + value: 30, + }, + ]); + + const protocolResult = await actions.save?.({ + cookies: {} as never, + request: protocolRequest(), + } as never); + + expect(protocolResult).toMatchObject({ + protocolSaved: true, + protocolMode: 'add', + }); + expect( + await ( + await database.prepare( + 'SELECT name,quality,primary_attribute_type FROM os_protocols', + ) + ).get(), + ).toEqual({ + name: 'Integration Protocol', + quality: 'Standard', + primary_attribute_type: 'Firepower', + }); + expect( + await ( + await database.prepare( + 'SELECT entity_type,action FROM audit_log ORDER BY created_at', + ) + ).all(), + ).toEqual([ + { + entity_type: 'game_data', + action: 'os_protocol_profile.created', + }, + { entity_type: 'game_data', action: 'os_protocol.created' }, + ]); + }); +}); diff --git a/src/routes/isac/protocols/talent-action.test.ts b/src/routes/isac/protocols/talent-action.test.ts new file mode 100644 index 0000000..1c1c9ed --- /dev/null +++ b/src/routes/isac/protocols/talent-action.test.ts @@ -0,0 +1,344 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const capturedRule = { + version: 1 as const, + simulationReadiness: 'captured_only' as const, + scenarioInputs: [], + pools: [], + clauses: [], +}; + +const mocks = vi.hoisted(() => { + const events: string[] = []; + const database = { + exec: vi.fn(async (sql: string) => { + events.push(sql); + }), + prepare: vi.fn(), + transactionAsync: vi.fn(), + }; + database.transactionAsync.mockImplementation( + (callback: (transaction: typeof database) => Promise) => { + const run = vi.fn(async () => { + events.push('BEGIN'); + try { + const result = await callback(database); + events.push('COMMIT'); + return result; + } catch (error) { + events.push('ROLLBACK'); + throw error; + } + }); + return Object.assign(run, { immediate: run }); + }, + ); + return { + events, + talentExists: false, + profileConfigured: true, + auditRecord: vi.fn(), + conditionalRuleSnapshot: vi.fn(), + parseConditionalRulePayload: vi.fn(), + recordIsacAudit: vi.fn(async () => { + events.push('audit'); + }), + saveConditionalRuleInTransaction: vi.fn(async () => { + events.push('save conditional rule'); + }), + database, + }; +}); + +vi.mock('$lib/server/auth/session', () => ({ + getSessionUser: vi.fn(async () => ({ id: 'operator-1' })), +})); + +vi.mock('$lib/server/auth/authorization', () => ({ + requireDataView: vi.fn(async (user) => user), + requirePermission: vi.fn(async (user) => user), +})); + +vi.mock('$lib/server/database', () => ({ + getDatabase: vi.fn(async () => mocks.database), +})); + +vi.mock('$lib/server/audit', () => ({ + auditRecord: mocks.auditRecord, + recordIsacAudit: mocks.recordIsacAudit, +})); + +vi.mock('$lib/server/data-moderation', () => ({ + getPendingEntities: vi.fn(async () => []), +})); + +vi.mock('$lib/server/os-protocol-profiles', () => ({ + getOsProtocolProfileAttributeIds: vi.fn(async () => ({ + Core: 'firepower', + Damage: 'weapon-damage', + Health: 'health', + })), +})); + +vi.mock('$lib/server/game-data-icons', () => ({ + applyIconMutation: vi.fn(), + gameDataIconMetadata: vi.fn(async () => null), + gameDataIconUrl: vi.fn(() => ''), + GameDataIconValidationError: class extends Error {}, + readIconMutation: vi.fn(async () => ({})), +})); + +vi.mock('$lib/server/managed-images', () => ({ + applyGameDataRender: vi.fn(), + ManagedImageValidationError: class extends Error {}, + managedImageUrl: vi.fn(() => ''), + processManagedImage: vi.fn(), + readGameDataRenderMutation: vi.fn(async () => ({})), +})); + +vi.mock('$lib/server/conditional-effects', () => ({ + conditionalRuleSnapshot: mocks.conditionalRuleSnapshot, + loadConditionalRuleDraft: vi.fn(), + parseConditionalRulePayload: mocks.parseConditionalRulePayload, + saveConditionalRuleInTransaction: mocks.saveConditionalRuleInTransaction, +})); + +import { actions } from './+page.server'; + +const protocolId = 'protocol-1'; +const talentId = 'talent-1'; +const protocolRecord = { + id: protocolId, + name: 'Adaptive Kernel', + quality: 'Standard', + primary_attribute_type: 'Firepower', + active: 1, +}; +const primaryEffect = { + protocol_id: protocolId, + stat_attribute_id: 'weapon-damage', + base_value: 5, +}; +const talentRecord = { + id: talentId, + protocol_id: protocolId, + name: 'Escalation Loop', + description_template: 'Captured Talent', +}; + +const saveRequest = ({ + id = '', + hasTalent = false, + confirmRemoval = false, + includePrimaryValue = true, +}: { + id?: string; + hasTalent?: boolean; + confirmRemoval?: boolean; + includePrimaryValue?: boolean; +} = {}) => { + const form = new FormData(); + form.set('id', id); + form.set('name', 'Adaptive Kernel'); + form.set('quality', 'Standard'); + form.set('attributeType', 'Firepower'); + form.set('primaryStatId', 'weapon-damage'); + if (includePrimaryValue) form.set('primaryBaseValue', '5'); + form.set('hasTalent', hasTalent ? '1' : '0'); + if (hasTalent) { + form.set('talentName', 'Escalation Loop'); + form.set('descriptionTemplate', 'Captured Talent'); + form.set('conditionalRule', JSON.stringify(capturedRule)); + } + if (confirmRemoval) form.set('confirmTalentRemoval', 'REMOVE'); + return new Request('http://localhost/isac/protocols?/save', { + method: 'POST', + body: form, + }); +}; + +const runSave = (options?: Parameters[0]) => + actions.save?.({ + cookies: {} as never, + request: saveRequest(options), + } as never); + +describe('OS Protocol optional Talent action', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.events.length = 0; + mocks.talentExists = false; + mocks.profileConfigured = true; + mocks.parseConditionalRulePayload.mockReturnValue(capturedRule); + mocks.conditionalRuleSnapshot.mockResolvedValue({ + id: 'rule-1', + source_id: talentId, + }); + mocks.auditRecord.mockResolvedValue(protocolRecord); + mocks.database.prepare.mockImplementation(async (sql: string) => ({ + get: vi.fn(async (...args: unknown[]) => { + if (sql.includes('FROM os_protocol_secondary_profiles')) + return mocks.profileConfigured ? { id: 'profile-1' } : null; + if (sql.includes('FROM os_protocols WHERE name=')) return null; + if (sql.includes('FROM os_protocol_talents WHERE protocol_id=')) + return mocks.talentExists ? talentRecord : null; + if (sql.includes('FROM os_protocol_talents WHERE name=')) return null; + if (sql.includes('COUNT(*) AS count FROM stat_attributes')) + return { count: args.length }; + if (sql.includes('FROM managed_images')) return null; + if (sql.includes('FROM os_protocol_primary_effects')) + return primaryEffect; + return null; + }), + all: vi.fn(async () => []), + run: vi.fn(async () => { + if (sql.includes('INSERT INTO os_protocol_talents')) + mocks.talentExists = true; + if (sql.includes('DELETE FROM conditional_effect_rules')) + mocks.events.push('delete conditional rule'); + if (sql.includes('DELETE FROM os_protocol_talents')) { + mocks.events.push('delete talent'); + mocks.talentExists = false; + } + }), + })); + }); + + it('creates a protocol without a Talent', async () => { + const result = await runSave(); + + expect(result).toMatchObject({ + protocolSaved: true, + protocolMode: 'add', + resultToken: expect.any(String), + }); + expect(mocks.parseConditionalRulePayload).not.toHaveBeenCalled(); + expect(mocks.saveConditionalRuleInTransaction).not.toHaveBeenCalled(); + expect( + mocks.database.prepare.mock.calls.some(([sql]) => + String(sql).includes('INSERT INTO os_protocol_talents'), + ), + ).toBe(false); + expect(mocks.recordIsacAudit).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: 'operator-1', + category: 'game_data', + action: 'os_protocol.created', + before: null, + after: expect.objectContaining({ talent: null, conditional: null }), + }), + ); + }); + + it('rejects a protocol whose shared profile is incomplete', async () => { + mocks.profileConfigured = false; + + const result = await runSave(); + + expect(result).toMatchObject({ + status: 400, + data: { + protocolError: + 'Configure the shared secondary profile for this quality and attribute type first.', + }, + }); + expect(mocks.database.transactionAsync).not.toHaveBeenCalled(); + expect(mocks.recordIsacAudit).not.toHaveBeenCalled(); + }); + + it('rejects a missing primary value instead of coercing it to zero', async () => { + const result = await runSave({ includePrimaryValue: false }); + + expect(result).toMatchObject({ status: 400 }); + expect(mocks.database.transactionAsync).not.toHaveBeenCalled(); + expect(mocks.recordIsacAudit).not.toHaveBeenCalled(); + }); + + it('accepts an assigned CAPTURE ONLY Talent with no mechanic branches', async () => { + const result = await runSave({ hasTalent: true }); + + expect(result).toMatchObject({ + protocolSaved: true, + protocolMode: 'add', + }); + expect(mocks.parseConditionalRulePayload).toHaveBeenCalledOnce(); + expect(mocks.saveConditionalRuleInTransaction).toHaveBeenCalledWith( + mocks.database, + 'os_protocol_talent', + expect.any(String), + capturedRule, + ); + }); + + it.each(['ready', 'requires_assumptions'] as const)( + 'rejects an empty %s mechanic program', + async (simulationReadiness) => { + mocks.parseConditionalRulePayload.mockReturnValue({ + ...capturedRule, + simulationReadiness, + }); + + const result = await runSave({ hasTalent: true }); + + expect(result).toMatchObject({ + status: 400, + data: { + protocolError: + 'Add a mechanic branch and outcome, or mark the Talent CAPTURE ONLY.', + }, + }); + expect(mocks.database.transactionAsync).not.toHaveBeenCalled(); + expect(mocks.recordIsacAudit).not.toHaveBeenCalled(); + }, + ); + + it('requires confirmation before removing an existing Talent', async () => { + mocks.talentExists = true; + + const result = await runSave({ id: protocolId }); + + expect(result).toMatchObject({ + status: 400, + data: { + protocolError: + 'Confirm removal of the existing Talent and mechanic program.', + }, + }); + expect(mocks.database.transactionAsync).not.toHaveBeenCalled(); + expect(mocks.recordIsacAudit).not.toHaveBeenCalled(); + }); + + it('removes an existing Talent and its mechanic in the audited transaction', async () => { + mocks.talentExists = true; + + const result = await runSave({ + id: protocolId, + confirmRemoval: true, + }); + + expect(result).toMatchObject({ + protocolSaved: true, + protocolMode: 'edit', + }); + expect(mocks.events).toEqual([ + 'BEGIN', + 'delete conditional rule', + 'delete talent', + 'audit', + 'COMMIT', + ]); + expect(mocks.recordIsacAudit).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: 'operator-1', + category: 'game_data', + action: 'os_protocol.updated', + entityId: protocolId, + before: expect.objectContaining({ + talent: talentRecord, + conditional: expect.objectContaining({ source_id: talentId }), + }), + after: expect.objectContaining({ talent: null, conditional: null }), + }), + ); + }); +}); diff --git a/src/routes/isac/skillchips/+page.server.ts b/src/routes/isac/skillchips/+page.server.ts index cf67107..91cd969 100644 --- a/src/routes/isac/skillchips/+page.server.ts +++ b/src/routes/isac/skillchips/+page.server.ts @@ -25,7 +25,10 @@ import { getRegistrySearch, getRegistrySort, } from '$lib/server/pagination'; -import { refreshAbilityFocuses } from '$lib/server/specialization-readiness'; +import { + refreshAbilityFocuses, + refreshSpecializationFocusReady, +} from '$lib/server/specialization-readiness'; import { fail } from '@sveltejs/kit'; import { randomUUID } from 'node:crypto'; import type { Actions, PageServerLoad } from './$types'; @@ -36,6 +39,18 @@ import { saveConditionalRuleInTransaction, } from '$lib/server/conditional-effects'; import { MECHANIC_RECIPE_REGISTRY } from '$lib/calculations/mechanic-recipes'; +import { + normalizeSkillChipProfiles, + SKILL_CHIP_QUALITIES, + SKILL_CHIP_SECONDARY_QUALITIES, +} from '$lib/skill-chips/profiles'; +import { + getSkillChipPrimaryAttributeIds, + getSkillChipPrimaryProfiles, + getSkillChipSecondaryPool, + replaceSkillChipPrimaryProfiles, + replaceSkillChipSecondaryPool, +} from '$lib/server/skill-chip-profiles'; const mechanicOutcomes = ( rule: ReturnType, @@ -68,6 +83,16 @@ const renderAuditMetadata = (render: Record | null | undefined) => } : null; +async function refreshAllFocusReadiness( + database: Awaited>, +) { + const focuses = await ( + await database.prepare('SELECT id FROM specialization_focuses') + ).all(); + for (const focus of focuses) + await refreshSpecializationFocusReady(database, String(focus.id)); +} + async function snapshot(id: string) { const database = await getDatabase(); const talent = await ( @@ -91,16 +116,6 @@ async function snapshot(id: string) { 'SELECT * FROM skill_chip_two_piece_bonuses WHERE skill_chip_set_id=?', ) ).get(id)) ?? null, - primaryOptions: await ( - await database.prepare( - 'SELECT stat_attribute_id FROM skill_chip_primary_options WHERE skill_chip_set_id=? ORDER BY stat_attribute_id', - ) - ).all(id), - secondaryOptions: await ( - await database.prepare( - 'SELECT stat_attribute_id FROM skill_chip_secondary_options WHERE skill_chip_set_id=? ORDER BY stat_attribute_id', - ) - ).all(id), talent: talent ?? null, talentEffects: talent ? await ( @@ -150,8 +165,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => { desc: "COALESCE((SELECT talent.name FROM skill_chip_talents talent WHERE talent.skill_chip_set_id=sets.id),'') COLLATE NOCASE DESC,sets.name COLLATE NOCASE ASC", }, rollPools: { - asc: '((SELECT COUNT(*) FROM skill_chip_primary_options primary_option WHERE primary_option.skill_chip_set_id=sets.id)+(SELECT COUNT(*) FROM skill_chip_secondary_options secondary_option WHERE secondary_option.skill_chip_set_id=sets.id)) ASC,sets.name COLLATE NOCASE ASC', - desc: '((SELECT COUNT(*) FROM skill_chip_primary_options primary_option WHERE primary_option.skill_chip_set_id=sets.id)+(SELECT COUNT(*) FROM skill_chip_secondary_options secondary_option WHERE secondary_option.skill_chip_set_id=sets.id)) DESC,sets.name COLLATE NOCASE ASC', + asc: 'sets.name COLLATE NOCASE ASC,sets.id ASC', + desc: 'sets.name COLLATE NOCASE DESC,sets.id DESC', }, state: { asc: 'sets.active ASC,sets.name COLLATE NOCASE ASC', @@ -173,7 +188,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => { : filter === 'configured' ? [ 'EXISTS (SELECT 1 FROM skill_chip_two_piece_bonuses bonus WHERE bonus.skill_chip_set_id=sets.id)', - 'EXISTS (SELECT 1 FROM skill_chip_talents talent WHERE talent.skill_chip_set_id=sets.id)', ] : []), ...(!requestedItemId && search @@ -198,7 +212,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active, SUM(CASE WHEN active=0 THEN 1 ELSE 0 END) AS archived, SUM(CASE WHEN specialization_ability_id IS NOT NULL THEN 1 ELSE 0 END) AS assigned, - SUM(CASE WHEN EXISTS (SELECT 1 FROM skill_chip_talents talent WHERE talent.skill_chip_set_id=skill_chip_sets.id) THEN 1 ELSE 0 END) AS configured + SUM(CASE WHEN EXISTS (SELECT 1 FROM skill_chip_two_piece_bonuses bonus WHERE bonus.skill_chip_set_id=skill_chip_sets.id) THEN 1 ELSE 0 END) AS configured FROM skill_chip_sets`) ).get(), ( @@ -211,7 +225,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { module: 'attributes', action: 'save', kind: 'stat', - fields: ['unit', 'chipMin', 'chipMax'], + fields: ['unit'], }, ]), getPendingEntities(user.id, [ @@ -224,8 +238,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { 'twoPieceStatId', 'twoPieceMin', 'twoPieceMax', - 'primaryStatIds', - 'secondaryStatIds', + 'hasTalent', 'talentName', 'descriptionTemplate', 'conditionalRule', @@ -252,8 +265,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => { const [ sets, twoPiece, - primaryOptions, - secondaryOptions, talents, talentEffects, stats, @@ -262,6 +273,9 @@ export const load: PageServerLoad = async ({ cookies, url }) => { abilityChipCounts, icons, renders, + primaryProfiles, + secondaryPool, + primaryAttributeIds, ] = await Promise.all([ ( await database.prepare( @@ -271,10 +285,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => { ( await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses') ).all(), - (await database.prepare('SELECT * FROM skill_chip_primary_options')).all(), - ( - await database.prepare('SELECT * FROM skill_chip_secondary_options') - ).all(), (await database.prepare('SELECT * FROM skill_chip_talents')).all(), ( await database.prepare( @@ -283,7 +293,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { ).all(), ( await database.prepare( - 'SELECT id,name,unit,skill_chip_min,skill_chip_max FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE', + 'SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE', ) ).all(), ( @@ -310,13 +320,14 @@ export const load: PageServerLoad = async ({ cookies, url }) => { "SELECT source_entity_id,id,updated_at FROM managed_images WHERE source_entity_type='skill_chip_render'", ) ).all(), + getSkillChipPrimaryProfiles(database), + getSkillChipSecondaryPool(database), + getSkillChipPrimaryAttributeIds(database), ]); const availableStats = [ ...pendingStats.map((item) => ({ ...item, unit: String(item.unit ?? ''), - skill_chip_min: item.chipMin === '' ? null : Number(item.chipMin), - skill_chip_max: item.chipMax === '' ? null : Number(item.chipMax), })), ...stats, ]; @@ -347,9 +358,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { const currentPendingSets = pendingSets.filter( (set) => Boolean(set.abilityId) && - Array.isArray(set.primaryStatIds) && - Array.isArray(set.secondaryStatIds) && - Boolean(set.conditionalRule), + (String(set.hasTalent) !== '1' || Boolean(set.conditionalRule)), ); const pendingSetRows = requestedItemId ? currentPendingSets.filter((set) => set.id === requestedItemId) @@ -374,19 +383,22 @@ export const load: PageServerLoad = async ({ cookies, url }) => { min_value: Number(set.twoPieceMin), max_value: Number(set.twoPieceMax), }, - primaryStatIds: set.primaryStatIds, - secondaryStatIds: set.secondaryStatIds, - talent: { - name: set.talentName, - description_template: set.descriptionTemplate, - conditionalRule: (() => { - try { - return JSON.parse(String(set.conditionalRule ?? 'null')); - } catch { - return null; - } - })(), - }, + talent: + String(set.hasTalent) === '1' + ? { + name: set.talentName, + description_template: set.descriptionTemplate, + conditionalRule: (() => { + try { + return JSON.parse( + String(set.conditionalRule ?? 'null'), + ); + } catch { + return null; + } + })(), + } + : null, })); const configuredSets = await Promise.all( sets.map(async (set) => { @@ -407,12 +419,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => { })(), twoPiece: twoPiece.find((item) => item.skill_chip_set_id === set.id) ?? null, - primaryStatIds: primaryOptions - .filter((item) => item.skill_chip_set_id === set.id) - .map((item) => String(item.stat_attribute_id)), - secondaryStatIds: secondaryOptions - .filter((item) => item.skill_chip_set_id === set.id) - .map((item) => String(item.stat_attribute_id)), talent: talent ? { ...talent, @@ -433,9 +439,15 @@ export const load: PageServerLoad = async ({ cookies, url }) => { return { sets: [...pendingSetRows, ...configuredSets], stats: availableStats, - rollStats: availableStats.filter( - (stat) => stat.skill_chip_min !== null && stat.skill_chip_max !== null, + primaryProfiles: primaryAttributeIds.map( + (statId) => + primaryProfiles.find((profile) => profile.statId === statId) ?? { + statId, + values: {}, + }, ), + primaryProfileBase: primaryProfiles, + secondaryPool, abilities: loadedAbilities, summary: { active: Number(summary?.active ?? 0), @@ -490,12 +502,7 @@ export const actions: Actions = { const twoPieceStatId = String(form.get('twoPieceStatId') ?? ''); const twoPieceMin = Number(form.get('twoPieceMin')); const twoPieceMax = Number(form.get('twoPieceMax')); - const primaryStatIds = [ - ...new Set(form.getAll('primaryStatIds').map(String).filter(Boolean)), - ]; - const secondaryStatIds = [ - ...new Set(form.getAll('secondaryStatIds').map(String).filter(Boolean)), - ]; + const hasTalent = form.get('hasTalent') === '1'; const talentName = String(form.get('talentName') ?? '') .normalize('NFKC') .trim(); @@ -503,25 +510,30 @@ export const actions: Actions = { .normalize('NFKC') .trim(); const database = await getDatabase(); - let conditionalRule; + let conditionalRule: ReturnType | null = + null; try { - const conditionalRuleRaw = form.get('conditionalRule'); - const activeStats = await ( - await database.prepare('SELECT id FROM stat_attributes WHERE active=1') - ).all(); - conditionalRule = parseConditionalRulePayload( - conditionalRuleRaw, - [ - { key: 'minimum', variantKey: 'minimum' }, - { key: 'maximum', variantKey: 'maximum' }, - ], - { - allowedAttributeIds: new Set( - activeStats.map((row) => String(row.id)), - ), - allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY)), - }, - ); + if (hasTalent) { + const conditionalRuleRaw = form.get('conditionalRule'); + const activeStats = await ( + await database.prepare( + 'SELECT id FROM stat_attributes WHERE active=1', + ) + ).all(); + conditionalRule = parseConditionalRulePayload( + conditionalRuleRaw, + [ + { key: 'minimum', variantKey: 'minimum' }, + { key: 'maximum', variantKey: 'maximum' }, + ], + { + allowedAttributeIds: new Set( + activeStats.map((row) => String(row.id)), + ), + allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY)), + }, + ); + } } catch (error) { return fail(400, { skillChipError: @@ -547,38 +559,30 @@ export const actions: Actions = { return fail(400, { skillChipError: 'Configure a valid 2-piece stat range.', }); - if (!primaryStatIds.length) - return fail(400, { - skillChipError: 'Configure at least one possible primary stat.', - }); - if (secondaryStatIds.length < 2) - return fail(400, { - skillChipError: 'Configure at least two possible secondary stats.', - }); - if (primaryStatIds.some((statId) => secondaryStatIds.includes(statId))) - return fail(400, { - skillChipError: 'Primary and secondary stat pools must be distinct.', - }); - const outcomes = mechanicOutcomes(conditionalRule); - const numberedOutcomes = descriptionOutcomes(conditionalRule); + const outcomes = conditionalRule ? mechanicOutcomes(conditionalRule) : []; + const numberedOutcomes = conditionalRule + ? descriptionOutcomes(conditionalRule) + : []; const durationSeconds = - conditionalRule.pools.find((pool) => pool.expiresAfterSeconds) + conditionalRule?.pools.find((pool) => pool.expiresAfterSeconds) ?.expiresAfterSeconds ?? null; - const maxStacks = conditionalRule.pools.length + const maxStacks = conditionalRule?.pools.length ? Math.max(...conditionalRule.pools.map((pool) => pool.max ?? 1)) : null; if ( - talentName.length < 2 || - talentName.length > 80 || - !descriptionTemplate || - !conditionalRule.clauses.length || - !outcomes.length + hasTalent && + (talentName.length < 2 || + talentName.length > 80 || + !descriptionTemplate || + !conditionalRule?.clauses.length || + !outcomes.length) ) return fail(400, { skillChipError: 'The 3-piece talent requires a name, description, mechanic branch, and outcome.', }); if ( + hasTalent && outcomes.some( (outcome) => outcome.variants.maximum.value < outcome.variants.minimum.value, @@ -592,6 +596,7 @@ export const actions: Actions = { (match) => Number(match[1]), ); if ( + hasTalent && placeholders.some( (index) => !numberedOutcomes.some( @@ -603,12 +608,16 @@ export const actions: Actions = { skillChipError: 'Description placeholders must reference configured numbered outcomes.', }); - if (descriptionTemplate.includes('{duration}') && durationSeconds === null) + if ( + hasTalent && + descriptionTemplate.includes('{duration}') && + durationSeconds === null + ) return fail(400, { skillChipError: 'The description uses {duration}, but no expiring state pool is defined.', }); - if (descriptionTemplate.includes('{max_stacks}') && !maxStacks) + if (hasTalent && descriptionTemplate.includes('{max_stacks}') && !maxStacks) return fail(400, { skillChipError: 'The description uses {max_stacks}, but no state pool maximum is defined.', @@ -656,11 +665,21 @@ export const actions: Actions = { ).get(id) : null; if ( - await ( + existingTalent && + !hasTalent && + form.get('confirmTalentRemoval') !== 'REMOVE' + ) + return fail(400, { + skillChipError: + 'Confirm removal of the existing 3-piece Talent and mechanic program.', + }); + if ( + hasTalent && + (await ( await database.prepare( 'SELECT id FROM skill_chip_talents WHERE name=? COLLATE NOCASE AND id<>?', ) - ).get(talentName, existingTalent?.id ?? 'new') + ).get(talentName, existingTalent?.id ?? 'new')) ) return fail(409, { skillChipError: 'That Skill Chip talent name is already in use.', @@ -668,14 +687,7 @@ export const actions: Actions = { const outcomeStatIds = outcomes .map((effect) => effect.attributeId) .filter((value): value is string => Boolean(value)); - const allStatIds = [ - ...new Set([ - twoPieceStatId, - ...primaryStatIds, - ...secondaryStatIds, - ...outcomeStatIds, - ]), - ]; + const allStatIds = [...new Set([twoPieceStatId, ...outcomeStatIds])]; const validStats = await ( await database.prepare( `SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${allStatIds.map(() => '?').join(',')})`, @@ -685,17 +697,6 @@ export const actions: Actions = { return fail(400, { skillChipError: 'One or more selected stats are unavailable.', }); - const rollStatIds = [...primaryStatIds, ...secondaryStatIds]; - const validRollStats = await ( - await database.prepare( - `SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND skill_chip_min IS NOT NULL AND skill_chip_max IS NOT NULL AND id IN (${rollStatIds.map(() => '?').join(',')})`, - ) - ).get(...rollStatIds); - if (Number(validRollStats?.count) !== rollStatIds.length) - return fail(400, { - skillChipError: - 'Every primary and secondary option requires a configured Skill Chip range.', - }); const setId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID(); const talentId = String(existingTalent?.id ?? randomUUID()); @@ -724,61 +725,50 @@ export const actions: Actions = { await database.prepare(`INSERT INTO skill_chip_two_piece_bonuses(id,skill_chip_set_id,stat_attribute_id,min_value,max_value) VALUES(?,?,?,?,?) ON CONFLICT(skill_chip_set_id) DO UPDATE SET stat_attribute_id=excluded.stat_attribute_id,min_value=excluded.min_value,max_value=excluded.max_value`) ).run(randomUUID(), setId, twoPieceStatId, twoPieceMin, twoPieceMax); - await ( - await database.prepare( - 'DELETE FROM skill_chip_primary_options WHERE skill_chip_set_id=?', - ) - ).run(setId); - for (const statId of primaryStatIds) + if (hasTalent && conditionalRule) { await ( - await database.prepare( - 'INSERT INTO skill_chip_primary_options(skill_chip_set_id,stat_attribute_id) VALUES(?,?)', - ) - ).run(setId, statId); - await ( - await database.prepare( - 'DELETE FROM skill_chip_secondary_options WHERE skill_chip_set_id=?', - ) - ).run(setId); - for (const statId of secondaryStatIds) - await ( - await database.prepare( - 'INSERT INTO skill_chip_secondary_options(skill_chip_set_id,stat_attribute_id) VALUES(?,?)', - ) - ).run(setId, statId); - await ( - await database.prepare(`INSERT INTO skill_chip_talents(id,skill_chip_set_id,name,description_template,created_at,updated_at) VALUES(?,?,?,?,?,?) + await database.prepare(`INSERT INTO skill_chip_talents(id,skill_chip_set_id,name,description_template,created_at,updated_at) VALUES(?,?,?,?,?,?) ON CONFLICT(skill_chip_set_id) DO UPDATE SET name=excluded.name,description_template=excluded.description_template,updated_at=excluded.updated_at`) - ).run(talentId, setId, talentName, descriptionTemplate, now, now); - await ( - await database.prepare( - 'DELETE FROM skill_chip_talent_effects WHERE talent_id=?', - ) - ).run(talentId); - let compatibilitySortOrder = 0; - for (const outcome of numberedOutcomes) { - if (outcome.kind !== 'attribute_modifier' || !outcome.attributeId) - continue; - compatibilitySortOrder += 1; + ).run(talentId, setId, talentName, descriptionTemplate, now, now); await ( await database.prepare( - 'INSERT INTO skill_chip_talent_effects(id,talent_id,stat_attribute_id,min_value,max_value,sort_order) VALUES(?,?,?,?,?,?)', + 'DELETE FROM skill_chip_talent_effects WHERE talent_id=?', ) - ).run( - randomUUID(), + ).run(talentId); + let compatibilitySortOrder = 0; + for (const outcome of numberedOutcomes) { + if (outcome.kind !== 'attribute_modifier' || !outcome.attributeId) + continue; + compatibilitySortOrder += 1; + await ( + await database.prepare( + 'INSERT INTO skill_chip_talent_effects(id,talent_id,stat_attribute_id,min_value,max_value,sort_order) VALUES(?,?,?,?,?,?)', + ) + ).run( + randomUUID(), + talentId, + outcome.attributeId, + outcome.variants.minimum.value, + outcome.variants.maximum.value, + compatibilitySortOrder, + ); + } + await saveConditionalRuleInTransaction( + database, + 'skill_chip_talent', talentId, - outcome.attributeId, - outcome.variants.minimum.value, - outcome.variants.maximum.value, - compatibilitySortOrder, + conditionalRule, ); + } else if (existingTalent) { + await ( + await database.prepare( + "DELETE FROM conditional_effect_rules WHERE source_type='skill_chip_talent' AND source_id=?", + ) + ).run(String(existingTalent.id)); + await ( + await database.prepare('DELETE FROM skill_chip_talents WHERE id=?') + ).run(String(existingTalent.id)); } - await saveConditionalRuleInTransaction( - database, - 'skill_chip_talent', - talentId, - conditionalRule, - ); await applyIconMutation( database, 'skill_chip', @@ -804,6 +794,7 @@ export const actions: Actions = { entityId: setId, before, after: await snapshot(setId), + database, }); await database.exec('COMMIT'); } catch (error) { @@ -816,33 +807,163 @@ export const actions: Actions = { resultToken: randomUUID(), }; }, + savePrimaryProfile: async ({ request, cookies }) => { + const operator = await requirePermission( + await getSessionUser(cookies), + 'data.manage', + ); + const database = await getDatabase(); + const form = await request.formData(); + const expectedStatIds = await getSkillChipPrimaryAttributeIds(database); + const normalized = normalizeSkillChipProfiles( + (() => { + try { + return JSON.parse(String(form.get('profiles') ?? '')); + } catch { + return null; + } + })(), + SKILL_CHIP_QUALITIES, + { expectedStatIds, minimumOptions: 4 }, + ); + if (normalized.error) + return fail(400, { skillChipPrimaryProfileError: normalized.error }); + await database.exec('BEGIN IMMEDIATE'); + try { + const before = await getSkillChipPrimaryProfiles(database); + if (JSON.stringify(before) !== String(form.get('baseProfiles') ?? '')) { + await database.exec('ROLLBACK'); + if (JSON.stringify(before) === JSON.stringify(normalized.profiles)) + return { + skillChipPrimaryProfileSaved: true, + resultToken: randomUUID(), + }; + return fail(409, { + skillChipPrimaryProfileError: + 'The shared primary profile changed after this editor opened. Review the latest values and try again.', + }); + } + await replaceSkillChipPrimaryProfiles(database, normalized.profiles); + await refreshAllFocusReadiness(database); + const after = await getSkillChipPrimaryProfiles(database); + await recordIsacAudit({ + actorId: operator.id, + category: 'game_data', + action: 'skill_chip_primary_profile.updated', + entityId: 'skill-chip-primary-profile', + before, + after, + database, + }); + await database.exec('COMMIT'); + } catch (error) { + await database.exec('ROLLBACK'); + throw error; + } + return { skillChipPrimaryProfileSaved: true, resultToken: randomUUID() }; + }, + saveSecondaryPool: async ({ request, cookies }) => { + const operator = await requirePermission( + await getSessionUser(cookies), + 'data.manage', + ); + const database = await getDatabase(); + const form = await request.formData(); + const normalized = normalizeSkillChipProfiles( + (() => { + try { + return JSON.parse(String(form.get('profiles') ?? '')); + } catch { + return null; + } + })(), + SKILL_CHIP_SECONDARY_QUALITIES, + { minimumOptions: 2 }, + ); + if (normalized.error) + return fail(400, { skillChipSecondaryPoolError: normalized.error }); + const statIds = normalized.profiles.map((profile) => profile.statId); + const validStats = await ( + await database.prepare( + `SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`, + ) + ).get(...statIds); + if (Number(validStats?.count) !== statIds.length) + return fail(400, { + skillChipSecondaryPoolError: + 'One or more selected Attributes are unavailable.', + }); + await database.exec('BEGIN IMMEDIATE'); + try { + const before = await getSkillChipSecondaryPool(database); + if (JSON.stringify(before) !== String(form.get('baseProfiles') ?? '')) { + await database.exec('ROLLBACK'); + if (JSON.stringify(before) === JSON.stringify(normalized.profiles)) + return { + skillChipSecondaryPoolSaved: true, + resultToken: randomUUID(), + }; + return fail(409, { + skillChipSecondaryPoolError: + 'The shared secondary pool changed after this editor opened. Review the latest values and try again.', + }); + } + await replaceSkillChipSecondaryPool(database, normalized.profiles); + await refreshAllFocusReadiness(database); + const after = await getSkillChipSecondaryPool(database); + await recordIsacAudit({ + actorId: operator.id, + category: 'game_data', + action: 'skill_chip_secondary_pool.updated', + entityId: 'skill-chip-secondary-pool', + before, + after, + database, + }); + await database.exec('COMMIT'); + } catch (error) { + await database.exec('ROLLBACK'); + throw error; + } + return { skillChipSecondaryPoolSaved: true, resultToken: randomUUID() }; + }, archive: async ({ request, cookies }) => { const operator = await requirePermission( await getSessionUser(cookies), 'data.manage', ); const id = String((await request.formData()).get('id') ?? ''); - const before = await snapshot(id); - if (!before.record) - return fail(404, { skillChipError: 'Skill Chip set not found.' }); const database = await getDatabase(); - await ( - await database.prepare( - 'UPDATE skill_chip_sets SET active=0,updated_at=? WHERE id=?', - ) - ).run(new Date().toISOString(), id); - await refreshAbilityFocuses( - database, - String(before.record.specialization_ability_id), - ); - await recordIsacAudit({ - actorId: operator.id, - category: 'game_data', - action: 'skill_chip.archived', - entityId: id, - before, - after: await snapshot(id), - }); + await database.exec('BEGIN IMMEDIATE'); + try { + const before = await snapshot(id); + if (!before.record) { + await database.exec('ROLLBACK'); + return fail(404, { skillChipError: 'Skill Chip set not found.' }); + } + await ( + await database.prepare( + 'UPDATE skill_chip_sets SET active=0,updated_at=? WHERE id=?', + ) + ).run(new Date().toISOString(), id); + await refreshAbilityFocuses( + database, + String(before.record.specialization_ability_id), + ); + await recordIsacAudit({ + actorId: operator.id, + category: 'game_data', + action: 'skill_chip.archived', + entityId: id, + before, + after: await snapshot(id), + database, + }); + await database.exec('COMMIT'); + } catch (error) { + await database.exec('ROLLBACK'); + throw error; + } return { skillChipArchived: true }; }, unarchive: async ({ request, cookies }) => { @@ -851,50 +972,63 @@ export const actions: Actions = { 'data.manage', ); const id = String((await request.formData()).get('id') ?? ''); - const before = await snapshot(id); - if (!before.record) - return fail(404, { skillChipError: 'Skill Chip set not found.' }); - const abilityId = String(before.record.specialization_ability_id); const database = await getDatabase(); - if ( - !(await ( - await database.prepare( - 'SELECT id FROM specialization_abilities WHERE id=? AND active=1', - ) - ).get(abilityId)) - ) { - return fail(400, { - skillChipError: - 'The assigned specialization ability must be active before restoring this Skill Chip.', - }); - } - const assignedCount = Number( - ( - await ( - await database.prepare(`SELECT COUNT(*) AS count FROM skill_chip_sets + await database.exec('BEGIN IMMEDIATE'); + try { + const before = await snapshot(id); + if (!before.record) { + await database.exec('ROLLBACK'); + return fail(404, { skillChipError: 'Skill Chip set not found.' }); + } + const abilityId = String(before.record.specialization_ability_id); + if ( + !(await ( + await database.prepare( + 'SELECT id FROM specialization_abilities WHERE id=? AND active=1', + ) + ).get(abilityId)) + ) { + await database.exec('ROLLBACK'); + return fail(400, { + skillChipError: + 'The assigned specialization ability must be active before restoring this Skill Chip.', + }); + } + const assignedCount = Number( + ( + await ( + await database.prepare(`SELECT COUNT(*) AS count FROM skill_chip_sets WHERE specialization_ability_id=? AND active=1 AND id<>?`) - ).get(abilityId, id) - )?.count ?? 0, - ); - if (assignedCount >= 3) - return fail(409, { - skillChipError: - 'That ability already has all three active Skill Chips assigned.', + ).get(abilityId, id) + )?.count ?? 0, + ); + if (assignedCount >= 3) { + await database.exec('ROLLBACK'); + return fail(409, { + skillChipError: + 'That ability already has all three active Skill Chips assigned.', + }); + } + await ( + await database.prepare( + 'UPDATE skill_chip_sets SET active=1,updated_at=? WHERE id=?', + ) + ).run(new Date().toISOString(), id); + await refreshAbilityFocuses(database, abilityId); + await recordIsacAudit({ + actorId: operator.id, + category: 'game_data', + action: 'skill_chip.unarchived', + entityId: id, + before, + after: await snapshot(id), + database, }); - await ( - await database.prepare( - 'UPDATE skill_chip_sets SET active=1,updated_at=? WHERE id=?', - ) - ).run(new Date().toISOString(), id); - await refreshAbilityFocuses(database, abilityId); - await recordIsacAudit({ - actorId: operator.id, - category: 'game_data', - action: 'skill_chip.unarchived', - entityId: id, - before, - after: await snapshot(id), - }); + await database.exec('COMMIT'); + } catch (error) { + await database.exec('ROLLBACK'); + throw error; + } return { skillChipUnarchived: true }; }, }; diff --git a/src/routes/isac/skillchips/+page.svelte b/src/routes/isac/skillchips/+page.svelte index cea7b25..b0b594c 100644 --- a/src/routes/isac/skillchips/+page.svelte +++ b/src/routes/isac/skillchips/+page.svelte @@ -10,6 +10,7 @@ import SearchableAssignment from '$lib/components/SearchableAssignment.svelte'; import SegmentedChoice from '$lib/components/SegmentedChoice.svelte'; import MechanicProgramEditor from '$lib/components/isac/MechanicProgramEditor.svelte'; + import SkillChipSharedProfiles from '$lib/components/SkillChipSharedProfiles.svelte'; import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor'; let { data, form } = $props(); @@ -22,7 +23,7 @@ clauses:[{key:'primary',label:'Primary',event:'skill_hit',if:{kind:'true'},reducer:{kind:'none'},mutations:[],outcomes:[{key:'effect-1',label:'Effect 1',phase:'while_active',kind:'attribute_modifier',scope:'assigned_ability',attributeId:'',basis:'flat',perStack:true,descriptionSlot:1,variants:{minimum:{value:0},maximum:{value:0}}}]}] }); const blankTalent = () => ({ id: '', name: '', description_template: '', conditionalRule: blankRule() }); - const blank = () => ({ id: '', name: '', abilityId: '', iconUrl: '', renderUrl: '', active: 1, twoPiece: { stat_attribute_id: '', min_value: '', max_value: '' }, primaryStatIds: [], secondaryStatIds: [], talent: blankTalent() }); + const blank = () => ({ id: '', name: '', abilityId: '', iconUrl: '', renderUrl: '', active: 1, twoPiece: { stat_attribute_id: '', min_value: '', max_value: '' }, hasTalent: false, talentOpen: false, talent: blankTalent() }); let editing = $state(null); let highEndCount = $state('0'); let handledResultToken = $state(''); @@ -65,9 +66,9 @@ ...item, abilityId: String(item.specialization_ability_id), twoPiece: item.twoPiece ? { ...item.twoPiece } : { stat_attribute_id: '', min_value: '', max_value: '' }, - primaryStatIds: [...item.primaryStatIds], - secondaryStatIds: [...item.secondaryStatIds], - talent: item.talent ? { ...item.talent, conditionalRule: item.talent.conditionalRule ?? blankRule() } : blankTalent() + hasTalent: Boolean(item.talent), + talentOpen: false, + talent: item.talent ? structuredClone({ ...item.talent, conditionalRule: item.talent.conditionalRule ?? blankRule() }) : blankTalent() }; highEndCount = '0'; }; @@ -86,17 +87,30 @@ editing = form.skillChipMode === 'add' ? blank() : null; highEndCount = '0'; } + if (form?.skillChipError && editing?.hasTalent) editing.talentOpen = true; }); Skill Chips — SHD.build admin -

    SKILL MODIFICATION SYNERGY REGISTRY

    SKILL CHIPS

    Configure Superior and High-end chip rolls, 2-piece bonuses, and quality-scaled 3-piece talents.

    +

    SKILL MODIFICATION SYNERGY REGISTRY

    SKILL CHIPS

    Configure shared quality rolls, ability-linked chip sets, 2-piece bonuses, and optional 3-piece talents.

    + + @@ -106,8 +120,9 @@

    {editing.id ? 'MODIFY CHIP SET' : 'NEW CHIP SET'}

    {editing.id ? editing.name : 'SKILL CHIP DEFINITION'}

    + -
    SUPPORTED QUALITY
    SUPERIORHIGH-END
    Only these qualities participate in chip scaling.
    +
    SUPPORTED QUALITY
    STANDARDSPECIALIZEDSUPERIORHIGH-END
    Shared profiles define primary and secondary ranges.
    SPECIALIZATION ABILITYEXCLUSIVE RELATIONSHIP · THREE ACTIVE SKILL CHIPS PER ABILITY
    {editing.abilityId ? `${ability(editing.abilityId)?.chipCount ?? 0} / 3 ASSIGNED` : 'REQUIRED'}
    @@ -118,15 +133,14 @@
    -
    -
    PRIMARY STAT POOLONE RANDOM STAT · USES ATTRIBUTE SKILL CHIP RANGE
    {editing.primaryStatIds.length}
    -
    SECONDARY STAT POOLTWO DISTINCT RANDOM STATS · USE ATTRIBUTE RANGES
    {editing.secondaryStatIds.length}
    -
    -
    -
    3-PIECE TALENTQUALITY-SCALED OUTCOMES · TYPED TRIGGER AND DESCRIPTION
    {previewOutcomes().length} OUTCOME{previewOutcomes().length === 1 ? '' : 'S'}
    +
    3-PIECE TALENTOPTIONAL SET BONUS · QUALITY-SCALED WHEN ASSIGNED
    { editing.hasTalent = values[0] === '1'; if (editing.hasTalent) editing.talentOpen = true; }}/>
    + {#if editing.hasTalent} +
    + {editing.talent.name || 'TALENT WORKSPACE'}{previewOutcomes().length} QUALITY-SCALED OUTCOME{previewOutcomes().length === 1 ? '' : 'S'}{editing.talentOpen ? 'COLLAPSE' : 'EXPAND'} +
    -
    {3 - Number(highEndCount)} SUPERIOR · {highEndCount} HIGH-END

    {talentPreview() || 'Talent description preview'}

    +
    {3 - Number(highEndCount)} NON-HIGH-END · {highEndCount} HIGH-END

    {talentPreview() || 'Talent description preview'}

    +
    +
    + {:else}

    This set has no 3-piece Talent. Its 2-piece bonus remains active when the requirement is met.

    {#if editing.talent.id}{/if}{/if}
    {#if form?.skillChipError}

    {form.skillChipError}

    {/if} -
    {#if editing.id}{/if}
    +
    {#if editing.id}{/if}
    {/if} @@ -150,9 +167,9 @@
    -
    QUALITY
    +
    QUALITYROLL PROFILE
    {#each data.sets as item} -
    {item.name}{item.pending ? `PROPOSED BY ${item.proposerName}` : 'SUPERIOR · HIGH-END'}
    {ability(String(item.specialization_ability_id))?.name ?? 'UNKNOWN ABILITY'}EXCLUSIVE ABILITY
    SUPHE{item.twoPiece ? `${stat(item.twoPiece.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(item.twoPiece.min_value, item.twoPiece.stat_attribute_id)}–${format(item.twoPiece.max_value, item.twoPiece.stat_attribute_id)}` : 'NOT CONFIGURED'}
    {item.talent?.name ?? 'NOT CONFIGURED'}{item.talent?.conditionalRule?.clauses?.length || item.pending ? 'MODELED · QUALITY SCALED' : 'CONFIG REQUIRED'}
    {item.primaryStatIds.length} PRIMARY · {item.secondaryStatIds.length} SECONDARY{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}{#if item.pending}{:else}{/if}
    +
    {item.name}{item.pending ? `PROPOSED BY ${item.proposerName}` : 'STANDARD · SPECIALIZED · SUPERIOR · HIGH-END'}
    {ability(String(item.specialization_ability_id))?.name ?? 'UNKNOWN ABILITY'}EXCLUSIVE ABILITY
    STDSPSUPHE{item.twoPiece ? `${stat(item.twoPiece.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(item.twoPiece.min_value, item.twoPiece.stat_attribute_id)}–${format(item.twoPiece.max_value, item.twoPiece.stat_attribute_id)}` : 'NOT CONFIGURED'}
    {item.talent?.name ?? 'NOT ASSIGNED'}{item.talent ? item.talent.conditionalRule?.clauses?.length || item.pending ? 'MODELED · QUALITY SCALED' : 'CONFIG REQUIRED' : 'OPTIONAL'}
    SHARED · 4 PRIMARY · {data.secondaryPool.length} SECONDARY{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}{#if item.pending}{:else}{/if}
    {#if editing?.id === item.id}{@render skillChipEditor()}{/if} - {:else}
    NO SKILL CHIP SETS REGISTEREDAdd the first Superior and High-end Skill Chip definition.
    {/each} + {:else}
    NO SKILL CHIP SETS REGISTEREDAdd the first ability-linked Skill Chip definition.
    {/each}
    diff --git a/src/routes/isac/skillchips/profile-actions.test.ts b/src/routes/isac/skillchips/profile-actions.test.ts new file mode 100644 index 0000000..70defe4 --- /dev/null +++ b/src/routes/isac/skillchips/profile-actions.test.ts @@ -0,0 +1,256 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => { + const database = { + exec: vi.fn(async () => undefined), + prepare: vi.fn(), + }; + return { + database, + getDatabase: vi.fn(async () => database), + getPrimaryAttributeIds: vi.fn(), + getPrimaryProfiles: vi.fn(), + getSecondaryPool: vi.fn(), + recordIsacAudit: vi.fn(), + refreshFocus: vi.fn(), + replacePrimaryProfiles: vi.fn(), + replaceSecondaryPool: vi.fn(), + requirePermission: vi.fn(async (user) => user), + }; +}); + +vi.mock('$lib/server/auth/session', () => ({ + getSessionUser: vi.fn(async () => ({ id: 'operator-1' })), +})); + +vi.mock('$lib/server/auth/authorization', () => ({ + requireDataView: vi.fn(async (user) => user), + requirePermission: mocks.requirePermission, +})); + +vi.mock('$lib/server/database', () => ({ + getDatabase: mocks.getDatabase, +})); + +vi.mock('$lib/server/audit', () => ({ + auditRecord: vi.fn(), + recordIsacAudit: mocks.recordIsacAudit, +})); + +vi.mock('$lib/server/data-moderation', () => ({ + getPendingEntities: vi.fn(async () => []), +})); + +vi.mock('$lib/server/skill-chip-profiles', () => ({ + getSkillChipPrimaryAttributeIds: mocks.getPrimaryAttributeIds, + getSkillChipPrimaryProfiles: mocks.getPrimaryProfiles, + getSkillChipSecondaryPool: mocks.getSecondaryPool, + replaceSkillChipPrimaryProfiles: mocks.replacePrimaryProfiles, + replaceSkillChipSecondaryPool: mocks.replaceSecondaryPool, +})); + +vi.mock('$lib/server/specialization-readiness', () => ({ + refreshAbilityFocuses: vi.fn(), + refreshSpecializationFocusReady: mocks.refreshFocus, +})); + +vi.mock('$lib/server/managed-images', () => ({ + applyGameDataRender: vi.fn(), + ManagedImageValidationError: class extends Error {}, + managedImageUrl: vi.fn(() => ''), + processManagedImage: vi.fn(), + readGameDataRenderMutation: vi.fn(), +})); + +import { actions } from './+page.server'; + +const primaryIds = [ + 'skill-duration', + 'skill-radius', + 'skill-cooldown-recovery', + 'skill-intensity', +]; +const primaryQualities = ['Standard', 'Specialized', 'Superior', 'High-end']; +const secondaryQualities = ['Specialized', 'Superior', 'High-end']; + +const profiles = (ids: string[], qualities: string[], offset = 0) => + ids.map((statId, profileIndex) => ({ + statId, + values: Object.fromEntries( + qualities.map((quality, qualityIndex) => [ + quality, + { + min: offset + profileIndex * 10 + qualityIndex + 1, + max: offset + profileIndex * 10 + qualityIndex + 2, + }, + ]), + ), + })); + +const primaryAfter = profiles(primaryIds, primaryQualities, 10); +const secondaryIds = ['critical-hit-chance', 'critical-hit-damage']; +const secondaryAfter = profiles(secondaryIds, secondaryQualities, 20); + +const actionRequest = (action: string, payload: unknown, base: unknown) => { + const form = new FormData(); + form.set('profiles', JSON.stringify(payload)); + form.set('baseProfiles', JSON.stringify(base)); + return new Request(`http://localhost/isac/skillchips?/${action}`, { + method: 'POST', + body: form, + }); +}; + +const cases = [ + { + actionName: 'savePrimaryProfile' as const, + payload: primaryAfter, + getProfiles: mocks.getPrimaryProfiles, + replaceProfiles: mocks.replacePrimaryProfiles, + before: profiles(primaryIds, primaryQualities), + after: profiles(primaryIds, primaryQualities, 30), + auditAction: 'skill_chip_primary_profile.updated', + entityId: 'skill-chip-primary-profile', + savedKey: 'skillChipPrimaryProfileSaved', + }, + { + actionName: 'saveSecondaryPool' as const, + payload: secondaryAfter, + getProfiles: mocks.getSecondaryPool, + replaceProfiles: mocks.replaceSecondaryPool, + before: profiles(secondaryIds, secondaryQualities), + after: profiles(secondaryIds, secondaryQualities, 40), + auditAction: 'skill_chip_secondary_pool.updated', + entityId: 'skill-chip-secondary-pool', + savedKey: 'skillChipSecondaryPoolSaved', + }, +]; + +describe('Skill Chip shared profile actions', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getPrimaryAttributeIds.mockResolvedValue(primaryIds); + mocks.database.prepare.mockImplementation(async (sql: string) => ({ + all: vi.fn(async () => + sql.includes('specialization_focuses') ? [{ id: 'focus-1' }] : [], + ), + get: vi.fn(async (...statIds: unknown[]) => ({ count: statIds.length })), + run: vi.fn(), + })); + }); + + for (const testCase of cases) { + it(`${testCase.actionName} audits complete evidence and commits its transaction`, async () => { + testCase.getProfiles + .mockResolvedValueOnce(testCase.before) + .mockResolvedValueOnce(testCase.after); + + const result = await actions[testCase.actionName]?.({ + cookies: {} as never, + request: actionRequest( + testCase.actionName, + testCase.payload, + testCase.before, + ), + } as never); + + expect(result).toMatchObject({ + [testCase.savedKey]: true, + resultToken: expect.any(String), + }); + expect(mocks.requirePermission).toHaveBeenCalledWith( + { id: 'operator-1' }, + 'data.manage', + ); + expect(testCase.getProfiles).toHaveBeenNthCalledWith(1, mocks.database); + expect(testCase.replaceProfiles).toHaveBeenCalledWith( + mocks.database, + testCase.payload, + ); + expect(testCase.getProfiles).toHaveBeenNthCalledWith(2, mocks.database); + expect(mocks.refreshFocus).toHaveBeenCalledWith( + mocks.database, + 'focus-1', + ); + expect(mocks.recordIsacAudit).toHaveBeenCalledWith({ + actorId: 'operator-1', + category: 'game_data', + action: testCase.auditAction, + entityId: testCase.entityId, + before: testCase.before, + after: testCase.after, + database: mocks.database, + }); + expect(mocks.database.exec.mock.calls).toEqual([ + ['BEGIN IMMEDIATE'], + ['COMMIT'], + ]); + }); + + it(`${testCase.actionName} rolls back and does not commit when audit fails`, async () => { + const auditError = new Error('audit insert failed'); + testCase.getProfiles + .mockResolvedValueOnce(testCase.before) + .mockResolvedValueOnce(testCase.after); + mocks.recordIsacAudit.mockRejectedValueOnce(auditError); + + await expect( + actions[testCase.actionName]?.({ + cookies: {} as never, + request: actionRequest( + testCase.actionName, + testCase.payload, + testCase.before, + ), + } as never), + ).rejects.toThrow(auditError); + + expect(mocks.database.exec.mock.calls).toEqual([ + ['BEGIN IMMEDIATE'], + ['ROLLBACK'], + ]); + expect(mocks.database.exec).not.toHaveBeenCalledWith('COMMIT'); + }); + + it(`${testCase.actionName} rejects stale baseProfiles without replacing or auditing`, async () => { + testCase.getProfiles.mockResolvedValueOnce(testCase.before); + + const result = await actions[testCase.actionName]?.({ + cookies: {} as never, + request: actionRequest( + testCase.actionName, + testCase.payload, + profiles(['stale-profile'], primaryQualities), + ), + } as never); + + expect(result).toMatchObject({ status: 409 }); + expect(testCase.replaceProfiles).not.toHaveBeenCalled(); + expect(mocks.refreshFocus).not.toHaveBeenCalled(); + expect(mocks.recordIsacAudit).not.toHaveBeenCalled(); + expect(mocks.database.exec.mock.calls).toEqual([ + ['BEGIN IMMEDIATE'], + ['ROLLBACK'], + ]); + }); + } + + it('does not open a database when data.manage permission is denied', async () => { + const permissionError = new Error('Forbidden'); + mocks.requirePermission.mockRejectedValueOnce(permissionError); + + await expect( + actions.savePrimaryProfile?.({ + cookies: {} as never, + request: actionRequest('savePrimaryProfile', primaryAfter, []), + } as never), + ).rejects.toThrow(permissionError); + + expect(mocks.requirePermission).toHaveBeenCalledWith( + { id: 'operator-1' }, + 'data.manage', + ); + expect(mocks.getDatabase).not.toHaveBeenCalled(); + expect(mocks.database.exec).not.toHaveBeenCalled(); + }); +}); diff --git a/src/routes/isac/skillchips/talent-removal-action.test.ts b/src/routes/isac/skillchips/talent-removal-action.test.ts new file mode 100644 index 0000000..e32f49e --- /dev/null +++ b/src/routes/isac/skillchips/talent-removal-action.test.ts @@ -0,0 +1,225 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => { + const events: string[] = []; + return { + events, + talentExists: true, + auditRecord: vi.fn(), + conditionalRuleSnapshot: vi.fn(), + gameDataIconMetadata: vi.fn(async () => null), + recordIsacAudit: vi.fn(async () => { + events.push('audit'); + }), + database: { + exec: vi.fn(async (sql: string) => { + events.push(sql); + }), + prepare: vi.fn(), + }, + }; +}); + +vi.mock('$lib/server/auth/session', () => ({ + getSessionUser: vi.fn(async () => ({ id: 'operator-1' })), +})); + +vi.mock('$lib/server/auth/authorization', () => ({ + requireDataView: vi.fn(async (user) => user), + requirePermission: vi.fn(async (user) => user), +})); + +vi.mock('$lib/server/database', () => ({ + getDatabase: vi.fn(async () => mocks.database), +})); + +vi.mock('$lib/server/audit', () => ({ + auditRecord: mocks.auditRecord, + recordIsacAudit: mocks.recordIsacAudit, +})); + +vi.mock('$lib/server/data-moderation', () => ({ + getPendingEntities: vi.fn(async () => []), +})); + +vi.mock('$lib/server/game-data-icons', () => ({ + applyIconMutation: vi.fn(), + gameDataIconMetadata: mocks.gameDataIconMetadata, + gameDataIconUrl: vi.fn(() => ''), + GameDataIconValidationError: class extends Error {}, + readIconMutation: vi.fn(async () => ({})), +})); + +vi.mock('$lib/server/managed-images', () => ({ + applyGameDataRender: vi.fn(), + ManagedImageValidationError: class extends Error {}, + managedImageUrl: vi.fn(() => ''), + processManagedImage: vi.fn(), + readGameDataRenderMutation: vi.fn(async () => ({})), +})); + +vi.mock('$lib/server/specialization-readiness', () => ({ + refreshAbilityFocuses: vi.fn(), + refreshSpecializationFocusReady: vi.fn(), +})); + +vi.mock('$lib/server/conditional-effects', () => ({ + conditionalRuleSnapshot: mocks.conditionalRuleSnapshot, + loadConditionalRuleDraft: vi.fn(), + parseConditionalRulePayload: vi.fn(), + saveConditionalRuleInTransaction: vi.fn(), +})); + +import { actions } from './+page.server'; + +const beforeRecord = { + id: 'set-1', + name: 'Pulse Matrix', + specialization_ability_id: 'ability-1', + active: 1, +}; +const afterRecord = { ...beforeRecord, updated_at: '2026-08-02T12:00:00.000Z' }; +const existingTalent = { + id: 'talent-1', + skill_chip_set_id: 'set-1', + name: 'Signal Cascade', + description_template: 'Existing Talent', +}; +const twoPiece = { + skill_chip_set_id: 'set-1', + stat_attribute_id: 'skill-duration', + min_value: 5, + max_value: 10, +}; +const talentEffects = [ + { + id: 'effect-1', + talent_id: 'talent-1', + stat_attribute_id: 'skill-intensity', + min_value: 1, + max_value: 2, + sort_order: 1, + }, +]; +const conditional = { id: 'rule-1', source_id: 'talent-1' }; + +const saveRequest = (confirmRemoval = false) => { + const form = new FormData(); + form.set('id', 'set-1'); + form.set('name', 'Pulse Matrix'); + form.set('abilityId', 'ability-1'); + form.set('twoPieceStatId', 'skill-duration'); + form.set('twoPieceMin', '5'); + form.set('twoPieceMax', '10'); + form.set('hasTalent', '0'); + if (confirmRemoval) form.set('confirmTalentRemoval', 'REMOVE'); + return new Request('http://localhost/isac/skillchips?/save', { + method: 'POST', + body: form, + }); +}; + +const runSave = (confirmRemoval = false) => + actions.save?.({ + cookies: {} as never, + request: saveRequest(confirmRemoval), + } as never); + +describe('Skill Chip Talent removal', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.events.length = 0; + mocks.talentExists = true; + mocks.auditRecord + .mockResolvedValueOnce(beforeRecord) + .mockResolvedValueOnce(afterRecord); + mocks.conditionalRuleSnapshot.mockResolvedValue(conditional); + mocks.database.prepare.mockImplementation(async (sql: string) => ({ + get: vi.fn(async (...args: unknown[]) => { + if (sql.includes('FROM specialization_abilities')) + return { id: 'ability-1' }; + if (sql.includes('COUNT(*) AS count FROM skill_chip_sets')) + return { count: 2 }; + if (sql.includes('FROM skill_chip_sets WHERE name=')) return null; + if (sql.includes('FROM skill_chip_talents WHERE skill_chip_set_id=')) + return mocks.talentExists ? existingTalent : null; + if (sql.includes('COUNT(*) AS count FROM stat_attributes')) + return { count: args.length }; + if (sql.includes('FROM managed_images')) return null; + if (sql.includes('FROM skill_chip_two_piece_bonuses')) return twoPiece; + return null; + }), + all: vi.fn(async () => { + if (sql.includes('FROM skill_chip_talent_effects')) + return talentEffects; + return []; + }), + run: vi.fn(async () => { + if (sql.includes('DELETE FROM conditional_effect_rules')) + mocks.events.push('delete conditional rule'); + if (sql.includes('DELETE FROM skill_chip_talents')) { + mocks.events.push('delete talent'); + mocks.talentExists = false; + } + }), + })); + }); + + it('requires explicit confirmation before removing an existing Talent', async () => { + const result = await runSave(); + + expect(result).toMatchObject({ + status: 400, + data: { + skillChipError: + 'Confirm removal of the existing 3-piece Talent and mechanic program.', + }, + }); + expect(mocks.database.exec).not.toHaveBeenCalled(); + expect(mocks.events).not.toContain('delete conditional rule'); + expect(mocks.events).not.toContain('delete talent'); + expect(mocks.recordIsacAudit).not.toHaveBeenCalled(); + }); + + it('removes the Talent and mechanic in one audited transaction after confirmation', async () => { + const result = await runSave(true); + + expect(result).toMatchObject({ + skillChipSaved: true, + skillChipMode: 'edit', + resultToken: expect.any(String), + }); + expect(mocks.events).toEqual([ + 'BEGIN', + 'delete conditional rule', + 'delete talent', + 'audit', + 'COMMIT', + ]); + expect(mocks.recordIsacAudit).toHaveBeenCalledWith({ + actorId: 'operator-1', + category: 'game_data', + action: 'skill_chip.updated', + entityId: 'set-1', + before: { + record: beforeRecord, + icon: null, + render: null, + twoPiece, + talent: existingTalent, + talentEffects, + conditional, + }, + after: { + record: afterRecord, + icon: null, + render: null, + twoPiece, + talent: null, + talentEffects: [], + conditional: null, + }, + database: mocks.database, + }); + }); +}); diff --git a/src/routes/isac/specializations/+page.svelte b/src/routes/isac/specializations/+page.svelte index 16c58ce..aa789dc 100644 --- a/src/routes/isac/specializations/+page.svelte +++ b/src/routes/isac/specializations/+page.svelte @@ -211,7 +211,7 @@ nodeEditing = null; }; const editSpecialization = (item: any) => { - specializationEditing = { id: item.id, name: item.name, description: item.description ?? '', active: Number(item.active), iconUrl: item.iconUrl ?? '' }; + specializationEditing = { id: item.id, name: item.name, description: item.description ?? '', active: Number(item.active), iconUrl: item.iconUrl ?? '', renderUrl: item.renderUrl ?? '' }; focusEditing = abilityEditing = nodeEditing = null; }; const editFocus = (item: any) => { @@ -249,7 +249,8 @@ } : blankAbility(item.specialization_id).attack, effects: (item.effects ?? []).map(normalizedEffect), active: Number(item.active), - iconUrl: item.iconUrl ?? '' + iconUrl: item.iconUrl ?? '', + renderUrl: item.renderUrl ?? '' }; specializationEditing = focusEditing = nodeEditing = null; combatEffectEditing = null; @@ -284,7 +285,8 @@ costs: { ...(rank.costs ?? {}) } })), active: Number(item.active), - iconUrl: item.iconUrl ?? '' + iconUrl: item.iconUrl ?? '', + renderUrl: item.renderUrl ?? '' }; specializationEditing = focusEditing = abilityEditing = null; }; diff --git a/src/routes/isac/weaponmods/+page.server.ts b/src/routes/isac/weaponmods/+page.server.ts index e16c5b9..8540530 100644 --- a/src/routes/isac/weaponmods/+page.server.ts +++ b/src/routes/isac/weaponmods/+page.server.ts @@ -21,18 +21,29 @@ import { import { fail } from '@sveltejs/kit'; import { randomUUID } from 'node:crypto'; import type { Actions, PageServerLoad } from './$types'; +import { + MOD_QUALITIES, + normalizeModSecondaryPool, +} from '$lib/mods/secondary-pool'; +import { + getModSecondaryPool, + replaceModSecondaryPool, +} from '$lib/server/mod-secondary-pools'; +import { + applyIconMutation, + gameDataIconMetadata, + gameDataIconUrl, + GameDataIconValidationError, + readIconMutation, +} from '$lib/server/game-data-icons'; +import { + WEAPON_MOD_SLOT_DEFINITIONS, + WEAPON_MOD_SLOT_TYPES, + weaponModSlotDefinitionById, +} from '$lib/weapons/mod-slots'; -const qualities = ['Standard', 'Specialized', 'Superior', 'High-end'] as const; -const slots = [ - 'Optics', - 'Muzzle', - 'Underbarrel', - 'Magazine', - 'Barrel', - 'Upper Receiver', - 'Lower Receiver', - 'Trigger', -] as const; +const qualities = MOD_QUALITIES; +const slots = WEAPON_MOD_SLOT_TYPES; const weaponCategories = [ 'shotgun', 'rifle', @@ -49,10 +60,6 @@ type QualityValue = { primaryLevel20Max: number; }; type Effect = { statId: string; values: Record }; -type SecondaryRange = { - statId: string; - values: Record; -}; type ExoticAttribute = { statId: string; level1Value: number; @@ -86,16 +93,6 @@ async function snapshot(id: string) { 'SELECT * FROM weapon_mod_quality_values WHERE weapon_mod_id=? ORDER BY quality', ) ).all(id), - secondaryOptions: await ( - await database.prepare( - 'SELECT stat_attribute_id FROM weapon_mod_secondary_options WHERE weapon_mod_id=? ORDER BY stat_attribute_id', - ) - ).all(id), - secondaryQualityValues: await ( - await database.prepare( - 'SELECT * FROM weapon_mod_secondary_quality_values WHERE weapon_mod_id=? ORDER BY stat_attribute_id,quality', - ) - ).all(id), exoticAttributes: await ( await database.prepare( 'SELECT * FROM weapon_mod_exotic_attributes WHERE weapon_mod_id=? ORDER BY sort_order', @@ -146,10 +143,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => { asc: 'CASE WHEN mods.exotic_weapon_id IS NULL THEN 0 ELSE 1 END ASC,mods.name COLLATE NOCASE ASC', desc: 'CASE WHEN mods.exotic_weapon_id IS NULL THEN 0 ELSE 1 END DESC,mods.name COLLATE NOCASE ASC', }, - secondaries: { - asc: '(SELECT COUNT(*) FROM weapon_mod_secondary_options option WHERE option.weapon_mod_id=mods.id) ASC,mods.name COLLATE NOCASE ASC', - desc: '(SELECT COUNT(*) FROM weapon_mod_secondary_options option WHERE option.weapon_mod_id=mods.id) DESC,mods.name COLLATE NOCASE ASC', - }, compatibility: { asc: "COALESCE(weapons.name,(SELECT MIN(link.weapon_category) FROM weapon_mod_compatible_categories link WHERE link.weapon_mod_id=mods.id),'') COLLATE NOCASE ASC,mods.name COLLATE NOCASE ASC", desc: "COALESCE(weapons.name,(SELECT MIN(link.weapon_category) FROM weapon_mod_compatible_categories link WHERE link.weapon_mod_id=mods.id),'') COLLATE NOCASE DESC,mods.name COLLATE NOCASE ASC", @@ -229,7 +222,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => { 'exoticWeaponId', 'compatibleCategories', 'qualityValues', - 'secondaryRanges', 'effects', 'exoticAttributes', ], @@ -260,14 +252,14 @@ export const load: PageServerLoad = async ({ cookies, url }) => { mods, compatibility, qualityRows, - secondaryOptions, - secondaryValues, exoticAttributes, effects, effectValues, stats, weapons, renders, + slotIcons, + secondaryPool, ] = await Promise.all([ ( await database.prepare(`SELECT mods.*,weapons.name AS exotic_weapon_name FROM weapon_mods mods @@ -278,14 +270,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => { await database.prepare('SELECT * FROM weapon_mod_compatible_categories') ).all(), (await database.prepare('SELECT * FROM weapon_mod_quality_values')).all(), - ( - await database.prepare('SELECT * FROM weapon_mod_secondary_options') - ).all(), - ( - await database.prepare( - 'SELECT * FROM weapon_mod_secondary_quality_values', - ) - ).all(), ( await database.prepare( 'SELECT * FROM weapon_mod_exotic_attributes ORDER BY sort_order', @@ -314,6 +298,12 @@ export const load: PageServerLoad = async ({ cookies, url }) => { "SELECT source_entity_id,id,updated_at FROM managed_images WHERE source_entity_type='weapon_mod_render'", ) ).all(), + ( + await database.prepare( + "SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='weapon_mod_slot'", + ) + ).all(), + getModSecondaryPool(database, 'weapon'), ]); const availableWeapons = [ ...pendingWeapons.map((item) => ({ @@ -331,7 +321,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => { ?.name ?? '', compatibleCategories: item.compatibleCategories, qualityValues: parseJson(String(item.qualityValues), []), - secondaryRanges: parseJson(String(item.secondaryRanges), []), exoticAttributes: parseJson(String(item.exoticAttributes), []), effects: parseJson(String(item.effects), []), })); @@ -352,23 +341,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => { primaryLevel20Min: row.primary_level_20_min, primaryLevel20Max: row.primary_level_20_max, })), - secondaryRanges: secondaryOptions - .filter((option) => option.weapon_mod_id === mod.id) - .map((option) => ({ - statId: String(option.stat_attribute_id), - values: Object.fromEntries( - secondaryValues - .filter( - (row) => - row.weapon_mod_id === mod.id && - row.stat_attribute_id === option.stat_attribute_id, - ) - .map((row) => [ - String(row.quality), - { min: Number(row.min_value), max: Number(row.max_value) }, - ]), - ), - })), exoticAttributes: exoticAttributes .filter((row) => row.weapon_mod_id === mod.id) .map((row) => ({ @@ -396,9 +368,19 @@ export const load: PageServerLoad = async ({ cookies, url }) => { })(), })), ], + secondaryPool, stats: [...pendingStats, ...stats], weapons: availableWeapons, - slots: slots.map((name) => ({ id: name, name })), + slots: WEAPON_MOD_SLOT_DEFINITIONS.map((definition) => { + const icon = slotIcons.find((row) => row.entity_id === definition.id); + return { + ...definition, + iconUrl: icon + ? gameDataIconUrl('weapon_mod_slot', definition.id, icon.updated_at) + : '', + updatedAt: icon ? String(icon.updated_at) : '', + }; + }), qualities, weaponCategories: weaponCategories.map((name) => ({ id: name, @@ -418,6 +400,141 @@ export const load: PageServerLoad = async ({ cookies, url }) => { }; export const actions: Actions = { + saveSlotIcon: async ({ request, cookies }) => { + const operator = await requirePermission( + await getSessionUser(cookies), + 'data.manage', + ); + const form = await request.formData(); + const id = String(form.get('id') ?? ''); + const slotType = String(form.get('slotType') ?? ''); + const definition = weaponModSlotDefinitionById(id); + if (!definition || definition.name !== slotType) { + return fail(400, { + weaponModSlotIconError: 'Choose a valid Weapon Mod slot icon target.', + }); + } + let mutation: Awaited>; + try { + mutation = await readIconMutation(form); + } catch (error) { + if (error instanceof GameDataIconValidationError) + return fail(400, { weaponModSlotIconError: error.message }); + throw error; + } + if (!mutation.output && !mutation.remove) { + return fail(400, { + weaponModSlotIconError: + 'Choose an icon to upload or remove the current icon.', + }); + } + const database = await getDatabase(); + await database.exec('BEGIN IMMEDIATE'); + try { + const beforeIcon = await gameDataIconMetadata( + database, + 'weapon_mod_slot', + definition.id, + ); + const baseUpdatedAt = String(form.get('baseUpdatedAt') ?? ''); + if (String(beforeIcon?.updated_at ?? '') !== baseUpdatedAt) { + await database.exec('ROLLBACK'); + return fail(409, { + weaponModSlotIconError: + 'This slot icon changed after the editor opened. Review the latest icon and try again.', + }); + } + if (mutation.remove && !beforeIcon) { + await database.exec('ROLLBACK'); + return fail(400, { + weaponModSlotIconError: 'This slot does not have an icon to remove.', + }); + } + await applyIconMutation( + database, + 'weapon_mod_slot', + definition.id, + mutation, + operator.id, + ); + const afterIcon = await gameDataIconMetadata( + database, + 'weapon_mod_slot', + definition.id, + ); + await recordIsacAudit({ + actorId: operator.id, + category: 'game_data', + action: 'weapon_mod_slot_icon.updated', + entityId: definition.id, + before: { slot: definition, icon: beforeIcon }, + after: { slot: definition, icon: afterIcon }, + database, + }); + await database.exec('COMMIT'); + } catch (error) { + await database.exec('ROLLBACK'); + throw error; + } + return { + weaponModSlotIconSaved: true, + resultToken: randomUUID(), + }; + }, + saveSecondaryPool: async ({ request, cookies }) => { + const operator = await requirePermission( + await getSessionUser(cookies), + 'data.manage', + ); + const form = await request.formData(); + if (String(form.get('id') ?? '') !== 'weapon-mod-secondary-pool') { + return fail(400, { + weaponModSecondaryPoolError: + 'The shared Weapon Mod pool target is invalid.', + }); + } + const normalized = normalizeModSecondaryPool( + parseJson(form.get('secondaryRanges'), null), + ); + if (normalized.error) { + return fail(400, { weaponModSecondaryPoolError: normalized.error }); + } + const database = await getDatabase(); + const statIds = normalized.pool.map((range) => range.statId); + const validStats = await ( + await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes + WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`) + ).get(...statIds); + if (Number(validStats?.count) !== statIds.length) { + return fail(400, { + weaponModSecondaryPoolError: + 'One or more selected Attributes are unavailable.', + }); + } + await database.exec('BEGIN IMMEDIATE'); + try { + const before = await getModSecondaryPool(database, 'weapon'); + await replaceModSecondaryPool(database, 'weapon', normalized.pool); + await recordIsacAudit({ + actorId: operator.id, + category: 'game_data', + action: 'weapon_mod_secondary_pool.updated', + entityId: 'weapon-mod-secondary-pool', + before, + after: await getModSecondaryPool(database, 'weapon'), + database, + }); + await database.exec('COMMIT'); + } catch (error) { + await database.exec('ROLLBACK'); + throw error; + } + return { + weaponModSecondaryPoolSaved: true, + weaponModSecondaryPoolMode: 'edit', + resultToken: randomUUID(), + }; + }, save: async ({ request, cookies }) => { const operator = await requirePermission( await getSessionUser(cookies), @@ -442,6 +559,12 @@ export const actions: Actions = { const name = String(form.get('name') ?? '') .normalize('NFKC') .trim(); + if (form.has('secondaryRanges')) { + return fail(400, { + weaponModError: + 'Secondary Attributes must be submitted through the shared pool editor.', + }); + } const slotType = String(form.get('slotType') ?? ''); const isExotic = form.get('isExotic') === '1'; const exoticWeaponId = String(form.get('exoticWeaponId') ?? ''); @@ -454,10 +577,6 @@ export const actions: Actions = { form.get('qualityValues'), [], ); - const secondaryRanges = parseJson( - form.get('secondaryRanges'), - [], - ); const effects = parseJson(form.get('effects'), []); const exoticAttributes = parseJson( form.get('exoticAttributes'), @@ -478,30 +597,6 @@ export const actions: Actions = { weaponModError: 'Exotic weapon assignment is only available for Exotic Weapon Mods.', }); - if ( - !isExotic && - (secondaryRanges.length < 2 || - secondaryRanges.length > 50 || - new Set(secondaryRanges.map((range) => range.statId)).size !== - secondaryRanges.length || - secondaryRanges.some( - (range) => - !range.statId || - qualities.some((quality) => { - const values = range.values?.[quality]; - return ( - !values || - !Number.isFinite(Number(values.min)) || - !Number.isFinite(Number(values.max)) || - Number(values.max) < Number(values.min) - ); - }), - )) - ) - return fail(400, { - weaponModError: - 'Configure at least two unique possible secondary attributes with valid ranges for every quality.', - }); if (isExotic && compatibleCategories.length) return fail(400, { weaponModError: @@ -599,10 +694,7 @@ export const actions: Actions = { ...new Set( isExotic ? exoticAttributes.map((attribute) => attribute.statId) - : [ - ...secondaryRanges.map((range) => range.statId), - ...effects.map((effect) => effect.statId), - ], + : effects.map((effect) => effect.statId), ), ]; const validStats = await ( @@ -671,29 +763,6 @@ export const actions: Actions = { row.primaryLevel20Min, row.primaryLevel20Max, ); - await ( - await database.prepare( - 'DELETE FROM weapon_mod_secondary_options WHERE weapon_mod_id=?', - ) - ).run(modId); - for (const range of isExotic ? [] : secondaryRanges) { - await ( - await database.prepare( - 'INSERT INTO weapon_mod_secondary_options(weapon_mod_id,stat_attribute_id) VALUES(?,?)', - ) - ).run(modId, range.statId); - for (const quality of qualities) - await ( - await database.prepare(`INSERT INTO weapon_mod_secondary_quality_values - (weapon_mod_id,stat_attribute_id,quality,min_value,max_value) VALUES(?,?,?,?,?)`) - ).run( - modId, - range.statId, - quality, - range.values[quality].min, - range.values[quality].max, - ); - } await ( await database.prepare( 'DELETE FROM weapon_mod_effects WHERE weapon_mod_id=?', diff --git a/src/routes/isac/weaponmods/+page.svelte b/src/routes/isac/weaponmods/+page.svelte index 1bec033..979b4cf 100644 --- a/src/routes/isac/weaponmods/+page.svelte +++ b/src/routes/isac/weaponmods/+page.svelte @@ -9,19 +9,20 @@ import GameIcon from '$lib/components/GameIcon.svelte'; import GameDataRenderUploadField from '$lib/components/GameDataRenderUploadField.svelte'; import type { GameIconName } from '$lib/game-icons'; + import ModSecondaryPoolEditor from '$lib/components/ModSecondaryPoolEditor.svelte'; + import WeaponModSlotIconPanel from '$lib/components/WeaponModSlotIconPanel.svelte'; let { data, form } = $props(); const qualityClass = (quality: string) => `quality-${quality.toLowerCase().replace('-', '')}`; const qualityRow = (quality: string) => ({ quality, primaryLevel1Min: 0, primaryLevel1Max: 0, primaryLevel20Min: 0, primaryLevel20Max: 0 }); - const blankSecondary = () => ({ statId: '', values: Object.fromEntries(data.qualities.map((quality: string) => [quality,{ min:0,max:0 }])) }); const blankEffect = () => ({ statId: '', values: Object.fromEntries(data.qualities.map((quality: string) => [quality, 0])) }); const blankExoticAttribute = () => ({ statId:'',level1Value:0,level20Value:0 }); const blank = () => ({ id: '', name: '', active: 1, slot_type: '', exotic_weapon_id: '', renderUrl: '', compatibleCategories: [], qualityValues: data.qualities.map(qualityRow), - secondaryRanges: [blankSecondary(),blankSecondary()], effects: [blankEffect()], exoticAttributes:[blankExoticAttribute()] + effects: [blankEffect()], exoticAttributes:[blankExoticAttribute()] }); let editing = $state(null); let levelPreview = $state(1); @@ -31,9 +32,8 @@ let renderResetToken = $state(0); let openedRequestedItem = $state(false); const categoryOptions = $derived(data.weaponCategories.map((category: any) => ({ value:category.id,label:category.name,icon:category.id as GameIconName,iconOnly:true }))); - const slotOptions = $derived(data.slots.map((slot: any) => ({ value:slot.id,label:slot.name.toUpperCase() }))); + const slotOptions = $derived(data.slots.map((slot: any) => ({ value:slot.name,label:slot.name.toUpperCase() }))); const exoticWeapons = $derived(data.weapons.filter((weapon: any) => Number(weapon.is_exotic))); - const stat = (id: string) => data.stats.find((item: any) => item.id === id); const weapon = (id: string) => data.weapons.find((item: any) => item.id === id); const previewRow = $derived(editing?.qualityValues.find((row: any) => row.quality === qualityPreview)); const interpolate = (start: unknown, end: unknown) => Number(start) + (Number(end) - Number(start)) * (levelPreview - 1) / 19; @@ -43,7 +43,6 @@ ...item, compatibleCategories: [...item.compatibleCategories], qualityValues: data.qualities.map((quality: string) => ({ ...qualityRow(quality), ...(item.qualityValues.find((row: any) => row.quality === quality) ?? {}) })), - secondaryRanges: item.secondaryRanges.length ? item.secondaryRanges.map((range: any) => ({ statId:range.statId,values:structuredClone(range.values) })) : [blankSecondary(),blankSecondary()], exoticAttributes: item.exoticAttributes.length ? item.exoticAttributes.map((attribute: any)=>({...attribute})) : [blankExoticAttribute()], effects: item.effects.length ? item.effects.map((effect: any) => ({ statId: effect.statId, values: { ...effect.values } })) : [blankEffect()] }; @@ -58,8 +57,6 @@ }; const addEffect = () => { if (editing.effects.length < 2) editing.effects = [...editing.effects, blankEffect()]; }; const removeEffect = (index: number) => { if (editing.effects.length > 1) editing.effects = editing.effects.filter((_: any, itemIndex: number) => itemIndex !== index); }; - const addSecondary = () => { if (editing.secondaryRanges.length < 50) editing.secondaryRanges = [...editing.secondaryRanges,blankSecondary()]; }; - const removeSecondary = (index: number) => { if (editing.secondaryRanges.length > 2) editing.secondaryRanges = editing.secondaryRanges.filter((_: any,itemIndex: number) => itemIndex !== index); }; const addExoticAttribute = () => { if(editing.exoticAttributes.length<12) editing.exoticAttributes=[...editing.exoticAttributes,blankExoticAttribute()]; }; const removeExoticAttribute = (index:number) => { if(editing.exoticAttributes.length>1) editing.exoticAttributes=editing.exoticAttributes.filter((_:any,itemIndex:number)=>itemIndex!==index); }; $effect(() => { @@ -81,6 +78,9 @@ Weapon Mods — ISAC

    ARMAMENT MODIFICATION REGISTRY

    WEAPON MODS

    Configure tier-aware slot compatibility, quality-scaled rolls, and fixed Mod Effects.

    + + + -
    @@ -130,16 +129,7 @@
    ({ value:quality,label:quality }))} bind:value={qualityPreview}/>
    - {#if previewRow}
    SELECTED PRIMARY · LEVEL {levelPreview}{display(interpolate(previewRow.primaryLevel1Min,previewRow.primaryLevel20Min))}–{display(interpolate(previewRow.primaryLevel1Max,previewRow.primaryLevel20Max))}
    SECONDARY ATTRIBUTE POOL{editing.secondaryRanges.length} OPTIONS
    {/if} -
    - - -
    -
    SECONDARY ATTRIBUTE POOLTHE BUILDER SELECTS TWO DISTINCT ATTRIBUTES · EVERY OPTION REQUIRES A RANGE AT EACH QUALITY
    -
    - {#each editing.secondaryRanges as range,index} -
    {String(index+1).padStart(2,'0')}itemIndex!==index).map((item: any)=>item.statId)} bind:value={range.statId}/>{#each data.qualities as quality}{/each}
    - {/each} + {#if previewRow}
    SELECTED PRIMARY · LEVEL {levelPreview}{display(interpolate(previewRow.primaryLevel1Min,previewRow.primaryLevel20Min))}–{display(interpolate(previewRow.primaryLevel1Max,previewRow.primaryLevel20Max))}
    SHARED SECONDARY POOL{data.secondaryPool.length} OPTIONS
    {/if}
    @@ -166,9 +156,9 @@
    -
    +
    SECONDARIES
    {#each data.mods as item} -
    {#if item.renderUrl}{/if}
    {item.name}{item.pending ? `PROPOSED BY ${item.proposerName}` : item.exotic_weapon_id ? 'EXOTIC · PRE-EQUIPPED' : 'STANDARD · SPECIALIZED · SUPERIOR · HIGH-END'}
    {item.slot_type}{#if item.exotic_weapon_id}FIXED{:else}ANY CORE{/if}{item.exotic_weapon_id ? `${item.exoticAttributes.length} FIXED` : `${item.secondaryRanges.length} POSSIBLE`} · {item.exotic_weapon_id ? 'LEVEL SCALED' : 'SELECT 2 IN BUILDER'}{item.exotic_weapon_id ? weapon(item.exotic_weapon_id)?.name ?? item.exotic_weapon_name : item.compatibleCategories.map((category: string) => category.toUpperCase()).join(' · ')}{item.exotic_weapon_id ? 'NO ROLLS' : `${item.effects.length} EFFECT${item.effects.length===1?'':'S'}`}{item.pending?'PENDING':Number(item.active)?'ACTIVE':'ARCHIVED'}{#if item.pending}{:else}{/if}
    +
    {#if item.renderUrl}{/if}
    {item.name}{item.pending ? `PROPOSED BY ${item.proposerName}` : item.exotic_weapon_id ? 'EXOTIC · PRE-EQUIPPED' : 'STANDARD · SPECIALIZED · SUPERIOR · HIGH-END'}
    {item.slot_type}{#if item.exotic_weapon_id}FIXED{:else}ANY CORE{/if}{item.exotic_weapon_id ? `${item.exoticAttributes.length} FIXED` : `${data.secondaryPool.length} SHARED`} · {item.exotic_weapon_id ? 'LEVEL SCALED' : '0 / 1 / 2 / 2'}{item.exotic_weapon_id ? weapon(item.exotic_weapon_id)?.name ?? item.exotic_weapon_name : item.compatibleCategories.map((category: string) => category.toUpperCase()).join(' · ')}{item.exotic_weapon_id ? 'NO ROLLS' : `${item.effects.length} EFFECT${item.effects.length===1?'':'S'}`}{item.pending?'PENDING':Number(item.active)?'ACTIVE':'ARCHIVED'}{#if item.pending}{:else}{/if}
    {#if editing?.id === item.id} {@render weaponModEditor()} diff --git a/src/routes/isac/weaponmods/secondary-pool-action.test.ts b/src/routes/isac/weaponmods/secondary-pool-action.test.ts new file mode 100644 index 0000000..8939deb --- /dev/null +++ b/src/routes/isac/weaponmods/secondary-pool-action.test.ts @@ -0,0 +1,166 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + audits: [] as Array>, + database: { + exec: vi.fn(async () => undefined), + prepare: vi.fn(async () => ({ + get: vi.fn(async (...statIds: unknown[]) => ({ count: statIds.length })), + })), + }, + getModSecondaryPool: vi.fn(), + recordIsacAudit: vi.fn(), + replaceModSecondaryPool: vi.fn(), + requirePermission: vi.fn(async (user) => user), +})); + +vi.mock('$lib/server/auth/session', () => ({ + getSessionUser: vi.fn(async () => ({ id: 'operator-1' })), +})); + +vi.mock('$lib/server/auth/authorization', () => ({ + requireDataView: vi.fn(async (user) => user), + requirePermission: mocks.requirePermission, +})); + +vi.mock('$lib/server/database', () => ({ + getDatabase: vi.fn(async () => mocks.database), +})); + +vi.mock('$lib/server/audit', () => ({ + auditRecord: vi.fn(), + recordIsacAudit: mocks.recordIsacAudit, +})); + +vi.mock('$lib/server/data-moderation', () => ({ + getPendingEntities: vi.fn(async () => []), +})); + +vi.mock('$lib/server/managed-images', () => ({ + applyGameDataRender: vi.fn(), + ManagedImageValidationError: class extends Error {}, + managedImageUrl: vi.fn(() => ''), + processManagedImage: vi.fn(), + readGameDataRenderMutation: vi.fn(), +})); + +vi.mock('$lib/server/mod-secondary-pools', () => ({ + getModSecondaryPool: mocks.getModSecondaryPool, + replaceModSecondaryPool: mocks.replaceModSecondaryPool, +})); + +import { actions } from './+page.server'; + +const endpoints = ( + level1Min: number, + level1Max: number, + level20Min: number, + level20Max: number, +) => ({ level1Min, level1Max, level20Min, level20Max }); + +const pool = (suffix: string, offset = 0) => [ + { + statId: `critical-hit-chance-${suffix}`, + values: { + Specialized: endpoints(1 + offset, 2 + offset, 3 + offset, 4 + offset), + Superior: endpoints(2 + offset, 3 + offset, 4 + offset, 5 + offset), + 'High-end': endpoints(3 + offset, 4 + offset, 5 + offset, 6 + offset), + }, + }, + { + statId: `critical-hit-damage-${suffix}`, + values: { + Specialized: endpoints(4 + offset, 5 + offset, 6 + offset, 7 + offset), + Superior: endpoints(5 + offset, 6 + offset, 7 + offset, 8 + offset), + 'High-end': endpoints(6 + offset, 7 + offset, 8 + offset, 9 + offset), + }, + }, +]; + +const request = (secondaryRanges: ReturnType) => { + const form = new FormData(); + form.set('id', 'weapon-mod-secondary-pool'); + form.set('secondaryRanges', JSON.stringify(secondaryRanges)); + return new Request('http://localhost/isac/weaponmods?/saveSecondaryPool', { + method: 'POST', + body: form, + }); +}; + +describe('Weapon Mod shared secondary pool action', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.audits.length = 0; + mocks.recordIsacAudit.mockImplementation(async (event) => { + mocks.audits.push(event); + }); + }); + + it('audits the complete contract and commits the transaction', async () => { + const before = pool('before'); + const after = pool('after', 1); + mocks.getModSecondaryPool + .mockResolvedValueOnce(before) + .mockResolvedValueOnce(after); + + const result = await actions.saveSecondaryPool?.({ + cookies: {} as never, + request: request(after), + } as never); + + expect(result).toMatchObject({ + weaponModSecondaryPoolSaved: true, + weaponModSecondaryPoolMode: 'edit', + resultToken: expect.any(String), + }); + expect(mocks.requirePermission).toHaveBeenCalledWith( + { id: 'operator-1' }, + 'data.manage', + ); + expect(mocks.replaceModSecondaryPool).toHaveBeenCalledWith( + mocks.database, + 'weapon', + after, + ); + expect(mocks.audits).toEqual([ + { + actorId: 'operator-1', + category: 'game_data', + action: 'weapon_mod_secondary_pool.updated', + entityId: 'weapon-mod-secondary-pool', + before, + after, + database: mocks.database, + }, + ]); + expect(mocks.database.exec.mock.calls).toEqual([ + ['BEGIN IMMEDIATE'], + ['COMMIT'], + ]); + }); + + it('rolls back without committing when the audit write fails', async () => { + const before = pool('before'); + const after = pool('after', 1); + const auditError = new Error('audit insert failed'); + mocks.getModSecondaryPool + .mockResolvedValueOnce(before) + .mockResolvedValueOnce(after); + mocks.recordIsacAudit.mockRejectedValueOnce(auditError); + + await expect( + actions.saveSecondaryPool?.({ + cookies: {} as never, + request: request(after), + } as never), + ).rejects.toThrow(auditError); + + expect(mocks.recordIsacAudit).toHaveBeenCalledOnce(); + expect(mocks.database.exec).toHaveBeenCalledWith('ROLLBACK'); + expect(mocks.database.exec).not.toHaveBeenCalledWith('COMMIT'); + expect(mocks.database.exec.mock.calls).toEqual([ + ['BEGIN IMMEDIATE'], + ['ROLLBACK'], + ]); + }); +}); diff --git a/src/routes/isac/weaponmods/slot-icon-action.test.ts b/src/routes/isac/weaponmods/slot-icon-action.test.ts new file mode 100644 index 0000000..e9d9fab --- /dev/null +++ b/src/routes/isac/weaponmods/slot-icon-action.test.ts @@ -0,0 +1,157 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { WEAPON_MOD_SLOT_DEFINITIONS } from '$lib/weapons/mod-slots'; + +const mocks = vi.hoisted(() => ({ + applyIconMutation: vi.fn(), + database: { + exec: vi.fn(async () => undefined), + prepare: vi.fn(async () => ({ get: vi.fn(), all: vi.fn() })), + }, + gameDataIconMetadata: vi.fn(), + readIconMutation: vi.fn(), + recordIsacAudit: vi.fn(), + requirePermission: vi.fn(async (user) => user), +})); + +vi.mock('$lib/server/auth/session', () => ({ + getSessionUser: vi.fn(async () => ({ id: 'operator-1' })), +})); +vi.mock('$lib/server/auth/authorization', () => ({ + requireDataView: vi.fn(async (user) => user), + requirePermission: mocks.requirePermission, +})); +vi.mock('$lib/server/database', () => ({ + getDatabase: vi.fn(async () => mocks.database), +})); +vi.mock('$lib/server/audit', () => ({ + auditRecord: vi.fn(), + recordIsacAudit: mocks.recordIsacAudit, +})); +vi.mock('$lib/server/data-moderation', () => ({ + getPendingEntities: vi.fn(async () => []), +})); +vi.mock('$lib/server/managed-images', () => ({ + applyGameDataRender: vi.fn(), + ManagedImageValidationError: class extends Error {}, + managedImageUrl: vi.fn(() => ''), + processManagedImage: vi.fn(), + readGameDataRenderMutation: vi.fn(), +})); +vi.mock('$lib/server/game-data-icons', () => ({ + applyIconMutation: mocks.applyIconMutation, + gameDataIconMetadata: mocks.gameDataIconMetadata, + gameDataIconUrl: vi.fn(() => ''), + GameDataIconValidationError: class extends Error {}, + readIconMutation: mocks.readIconMutation, +})); +vi.mock('$lib/server/mod-secondary-pools', () => ({ + getModSecondaryPool: vi.fn(), + replaceModSecondaryPool: vi.fn(), +})); + +import { actions } from './+page.server'; + +const definition = WEAPON_MOD_SLOT_DEFINITIONS[0]; + +function actionRequest(overrides: Record = {}) { + const form = new FormData(); + form.set('id', overrides.id ?? definition.id); + form.set('name', `${definition.name} Slot`); + form.set('slotType', overrides.slotType ?? definition.name); + form.set('baseUpdatedAt', overrides.baseUpdatedAt ?? 'before-version'); + return new Request('http://localhost/isac/weaponmods?/saveSlotIcon', { + method: 'POST', + body: form, + }); +} + +const invoke = (request = actionRequest()) => + actions.saveSlotIcon?.({ cookies: {} as never, request } as never); + +describe('Weapon Mod shared slot icon action', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.gameDataIconMetadata.mockReset(); + mocks.readIconMutation.mockReset(); + mocks.recordIsacAudit.mockReset(); + mocks.readIconMutation.mockResolvedValue({ + output: Buffer.from('icon'), + remove: false, + }); + mocks.gameDataIconMetadata + .mockResolvedValueOnce({ byte_size: 10, updated_at: 'before-version' }) + .mockResolvedValueOnce({ byte_size: 20, updated_at: 'after-version' }); + }); + + it('validates the target, saves, audits, and commits', async () => { + const result = await invoke(); + + expect(result).toMatchObject({ + weaponModSlotIconSaved: true, + resultToken: expect.any(String), + }); + expect(mocks.requirePermission).toHaveBeenCalledWith( + { id: 'operator-1' }, + 'data.manage', + ); + expect(mocks.applyIconMutation).toHaveBeenCalledWith( + mocks.database, + 'weapon_mod_slot', + definition.id, + { output: Buffer.from('icon'), remove: false }, + 'operator-1', + ); + expect(mocks.recordIsacAudit).toHaveBeenCalledWith({ + actorId: 'operator-1', + category: 'game_data', + action: 'weapon_mod_slot_icon.updated', + entityId: definition.id, + before: { + slot: definition, + icon: { byte_size: 10, updated_at: 'before-version' }, + }, + after: { + slot: definition, + icon: { byte_size: 20, updated_at: 'after-version' }, + }, + database: mocks.database, + }); + expect(mocks.database.exec.mock.calls).toEqual([ + ['BEGIN IMMEDIATE'], + ['COMMIT'], + ]); + }); + + it('rejects a mismatched slot identity before reading the upload', async () => { + const result = await invoke(actionRequest({ slotType: 'Muzzle' })); + + expect(result).toMatchObject({ status: 400 }); + expect(mocks.readIconMutation).not.toHaveBeenCalled(); + expect(mocks.database.exec).not.toHaveBeenCalled(); + }); + + it('rejects stale editor data without changing the icon', async () => { + const result = await invoke( + actionRequest({ baseUpdatedAt: 'stale-version' }), + ); + + expect(result).toMatchObject({ status: 409 }); + expect(mocks.applyIconMutation).not.toHaveBeenCalled(); + expect(mocks.recordIsacAudit).not.toHaveBeenCalled(); + expect(mocks.database.exec.mock.calls).toEqual([ + ['BEGIN IMMEDIATE'], + ['ROLLBACK'], + ]); + }); + + it('rolls back when audit persistence fails', async () => { + const auditError = new Error('audit failed'); + mocks.recordIsacAudit.mockRejectedValueOnce(auditError); + + await expect(invoke()).rejects.toThrow(auditError); + expect(mocks.database.exec.mock.calls).toEqual([ + ['BEGIN IMMEDIATE'], + ['ROLLBACK'], + ]); + }); +}); diff --git a/tools/game-data-capture/contracts/README.md b/tools/game-data-capture/contracts/README.md index 02ab64f..2b7cac0 100644 --- a/tools/game-data-capture/contracts/README.md +++ b/tools/game-data-capture/contracts/README.md @@ -1,40 +1,133 @@ # Game Data Capture Contracts -RG-1 freezes contract version 1 for the taxonomy, workflow states, evidence model, archive manifest, and ISAC handoff boundary. The approved GDC-001 mod-family change extends version 1 with `weapon_mod` and `gear_mod`. +RG-1 freezes contract version 1 for the taxonomy, workflow states, evidence +model, archive manifest, and ISAC handoff boundary. The approved GDC-001 +mod-family change extends version 1 with `weapon_mod` and `gear_mod`. ## Files -- `domain-contract-v1.json` lists every family/view pair, record type, Gear slot, abstention reason, state, and allowed transition. -- `screenshot-classification.schema.json` validates one hierarchical screen classification. -- `evidence-archive-manifest-v1.schema.json` validates a private evidence archive manifest. -- `isac-handoff-v1.schema.json` validates a bounded draft handoff for authenticated ISAC intake. -- `examples/classification/` contains one valid and one invalid classification for each family, including Weapon Mod, Gear Mod, and explicit `unknown` abstention. -- `examples/archive/` and `examples/handoff/` exercise each versioned document schema. +- `domain-contract-v1.json` lists every family/view pair, record type, Gear + slot, abstention reason, state, and allowed transition. +- `screenshot-classification.schema.json` validates one hierarchical screen + classification. +- `evidence-archive-manifest-v1.schema.json` validates a private evidence + archive manifest. +- `isac-handoff-v1.schema.json` validates a bounded draft handoff for + authenticated ISAC intake. +- `examples/classification/` contains one valid and one invalid classification + for each family, including Weapon Mod, Gear Mod, and explicit `unknown` + abstention. +- `examples/archive/` and `examples/handoff/` exercise each versioned document + schema. -The schemas use JSON Schema Draft 2020-12, internal `$ref` values, standard formats, and no implementation-specific keywords. Python `jsonschema` and TypeScript validators such as Ajv can compile the files without model conversion. Consumers must reject a document unless `schema_version` equals the schema's `const` value. Consumers must also run semantic reference validation because JSON Schema cannot prove that an ID points to an item in another array. +The schemas use JSON Schema Draft 2020-12, internal `$ref` values, standard +formats, and no implementation-specific keywords. Python `jsonschema` and +TypeScript validators such as Ajv can compile the files without model +conversion. Consumers must reject a document unless `schema_version` equals the +schema's `const` value. Consumers must also run semantic reference validation +because JSON Schema cannot prove that an ID points to an item in another array. ## Mod Screen Semantics -Repository source data and editors establish `weapon_mod` and `gear_mod` as separate canonical aggregates. Each family currently supports only the `detail` view and its same-named record type. A Mod classification never sets the six-slot `gear_slot` field. The Weapon Mod slot, quality, compatibility, secondary-option, Mod Effect, exotic-attribute, and weapon-lock data and the Gear Mod quality and secondary-option data remain evidence-backed fields or relationships on the owning aggregate. They are not separate screen views or record types. +Repository source data and editors establish `weapon_mod` and `gear_mod` as +separate canonical aggregates. Each family currently supports only the `detail` +view and its same-named record type. A Mod classification never sets the +six-slot `gear_slot` field. A Weapon Mod detail screenshot may provide evidence +for item-owned slot, primary quality ranges, compatibility, Mod Effects, exotic +Attributes, and weapon-lock data. A Gear Mod detail screenshot may provide +evidence for its item-owned primary quality ranges. These fields and +relationships are not separate screen views or record types. -The `record_types` array still supports composite screens. No repository evidence establishes another independent canonical record type on a Weapon Mod or Gear Mod detail screen, so these two mappings each contain one record type. A classifier must use `unknown` with an abstention reason for another Mod view, an ambiguous family, or unsupported content. +Each mod family has one global canonical secondary Attribute pool configured +outside individual item details. The pool stores Level 1 and Level 20 minimum +and maximum ranges for Specialized, Superior, and High-end quality. Capacity is +zero for Standard, one for Specialized, and two each for Superior and High-end. +A secondary roll visible on an item-detail screenshot is evidence for that +observed item instance; it does not establish a pool option, canonical range +endpoint, or family-wide capacity. + +The `record_types` array still supports composite screens. No repository +evidence establishes another independent canonical record type on a Weapon Mod +or Gear Mod detail screen, so these two mappings each contain one record type. A +classifier must use `unknown` with an abstention reason for another Mod view, an +ambiguous family, or unsupported content. + +## Skill Chip Screen Semantics + +The implemented Skill Chip model assigns each definition to one specialization +ability. Each set owns one 2-piece bonus and may own one 3-piece Talent. One +shared fixed primary profile serves every definition: Skill Duration, Skill +Radius, Skill Cooldown Recovery, and Skill Intensity each store direct minimum +and maximum values for Standard, Specialized, Superior, and High-end. These +primary values apply to the assigned ability. Current Agent and combat totals +exclude them and present them as `NOT YET DEFINED`. + +One shared secondary Attribute pool also serves every definition. Its entries +store direct minimum and maximum values for Specialized, Superior, and High-end. +Secondary capacity is zero for Standard, one for Specialized, and two each for +Superior and High-end. Skill Chips have no item levels, so workers must not +infer level curves or level-based endpoints from Skill Chip evidence. + +Every equipped quality counts toward the two-piece threshold. Only equipped +High-end chips increase `h` in `minimum + (maximum - minimum) * h / 3`, where +`h` ranges from zero through three. The unchanged version 1 contract vocabulary +uses `skill_chip_roll_pool` for captured roll-profile evidence. Handoff +consumers must map that evidence to the shared primary profile or shared +secondary pool and must not attach either profile to an individual definition. +The JSON schemas remain unchanged. ## Version Decision -The approved additive taxonomy change remains schema version 1. The document shapes, trust boundary, bounds, and meanings of every existing value are unchanged, and all existing version 1 labels and documents remain valid. This utility and its payloads are prototype formats; no production-stable archive or handoff version has been declared. A version increment would force a document migration without representing a structural or semantic break. +The approved additive taxonomy change remains schema version 1. The document +shapes, trust boundary, bounds, and meanings of every existing value are +unchanged, and all existing version 1 labels and documents remain valid. This +utility and its payloads are prototype formats; no production-stable archive or +handoff version has been declared. A version increment would force a document +migration without representing a structural or semantic break. -Consumers must update their closed version 1 vocabularies before they accept Mod labels. Older consumers reject the new values, which keeps the boundary fail closed. Existing stored labels need validation against the revised vocabulary but no value rewrite. The capture-label UI, storage regression coverage, classifier, extraction dispatch, evidence export, and ISAC adapter mappings must consume both families and their exact `detail`/record-type pairs before their respective gates can pass. This contract change does not alter those downstream implementations. +Consumers must update their closed version 1 vocabularies before they accept Mod +labels. Older consumers reject the new values, which keeps the boundary fail +closed. Existing stored labels need validation against the revised vocabulary +but no value rewrite. The capture-label UI, storage regression coverage, +classifier, extraction dispatch, evidence export, and ISAC adapter mappings must +consume both families and their exact `detail`/record-type pairs before their +respective gates can pass. This contract change does not alter those downstream +implementations. ## Evidence Rules -Capture workers store source text, a normalized value, confidence, engine identity, an image digest, a bounded region, and append-only correction history for each evidence value. A null normalized value requires a `missing_reason`. A present normalized value requires a null `missing_reason`. Each correction's `before` state must match the preceding `after` state. Timestamps must increase, and the final `after` state must match the current evidence value. +Capture workers store source text, a normalized value, confidence, engine +identity, an image digest, a bounded region, and append-only correction history +for each evidence value. A null normalized value requires a `missing_reason`. A +present normalized value requires a null `missing_reason`. Each correction's +`before` state must match the preceding `after` state. Timestamps must increase, +and the final `after` state must match the current evidence value. -Workers record the configured item shown on screen under `observed_instances`, including its visible level, quality, tier, upgrade state, and values. Workers place proposed reusable definitions under `canonical_candidates` and handoff `records`. A worker must not copy an observed roll, relationship, mechanic, range endpoint, or option list into a canonical candidate unless evidence establishes that canonical claim. +Workers record the configured item shown on screen under `observed_instances`, +including visible values and any applicable quality, tier, upgrade state, or +level. Skill Chip observations omit item level because Skill Chips have none. +Workers place proposed reusable definitions under `canonical_candidates` and +handoff `records`. For Weapon Mods, Gear Mods, and Skill Chips, item-detail +evidence remains separate from each family’s shared canonical profiles. A worker +must not copy an observed roll, relationship, mechanic, range endpoint, option +list, or secondary capacity into a canonical candidate unless evidence +establishes that canonical claim. ## Trust Boundary -Archive and handoff documents contain content hashes and logical artifact IDs. They exclude host paths and credentials. The handoff declares `mutation_authority: "none"`. Importing or inspecting it grants no permission and performs no mutation. An authenticated ISAC action must revalidate each draft and preserve contributor moderation and audit behavior. +Archive and handoff documents contain content hashes and logical artifact IDs. +They exclude host paths and credentials. The handoff declares +`mutation_authority: "none"`. Importing or inspecting it grants no permission +and performs no mutation. An authenticated ISAC action must revalidate each +draft and preserve contributor moderation and audit behavior. -Archive images and handoff images allow at most 64 MiB. Private archive videos allow at most 16 GiB, matching the configured capture ceiling. Schema validation enforces media-specific byte limits. Semantic validation enforces unique entity and correction IDs, local references, evidence artifact hashes, correction chains, and normalized image bounds. +Archive images and handoff images allow at most 64 MiB. Private archive videos +allow at most 16 GiB, matching the configured capture ceiling. Schema validation +enforces media-specific byte limits. Semantic validation enforces unique entity +and correction IDs, local references, evidence artifact hashes, correction +chains, and normalized image bounds. -The contracts assign explicit states or reasons to cancellation, resource exhaustion, ambiguous classification, unsupported content, and extraction failure. Recovery requires a listed transition. Unknown transitions and future schema versions fail closed. +The contracts assign explicit states or reasons to cancellation, resource +exhaustion, ambiguous classification, unsupported content, and extraction +failure. Recovery requires a listed transition. Unknown transitions and future +schema versions fail closed.