feat(isac): link skill chips to abilities

This commit is contained in:
JB
2026-07-26 17:04:47 -04:00
parent 6722665f5c
commit b1656c430f
19 changed files with 681 additions and 424 deletions
+37 -3
View File
@@ -1,5 +1,39 @@
# Repository Instructions # Repository Instructions
## Parallel task execution
For substantial changes, use subagents as a standard practice when the work can
be divided into concrete, independent tasks that provide real parallel progress.
1. Delegate bounded mechanical work to faster, lower-reasoning agents. Suitable
tasks include focused code discovery, repetitive call-site updates,
documentation, and isolated test coverage.
2. Keep architecture, security-sensitive behavior, database migrations, shared
data-model decisions, and complex debugging with a suitably capable agent.
3. Assign explicit file or subsystem ownership before parallel edits. Avoid
having multiple agents edit the same files unless their work is deliberately
sequenced.
4. Do not delegate tightly coupled work merely to increase agent count. Keep it
with the primary agent when coordination would cost more than it saves.
5. The primary agent remains responsible for integrating results, reviewing
diffs, resolving cross-cutting concerns, and running the final required
validation.
6. Subagents must follow every repository rule that applies to their assigned
work, including design-system maintenance, audit logging, and database
preservation.
## Documentation prose
Use the `stop-slop` skill whenever an agent creates or changes project
documentation. Apply its review to the complete affected document, including
sections that the change did not introduce.
Preserve technical meaning, commands, paths, formulas, links, policy strength,
and product terminology. Remove filler, vague claims, formulaic contrasts,
manufactured emphasis, false agency, and repetition. Prefer active voice and
specific subjects. Do not weaken terms such as `must`, `never`, or `always`
when they express a real requirement.
## SHD design-system maintenance ## SHD design-system maintenance
Treat `docs/DESIGN_SYSTEM.md` and the shared tokens and patterns in `src/app.css` Treat `docs/DESIGN_SYSTEM.md` and the shared tokens and patterns in `src/app.css`
@@ -86,7 +120,7 @@ Follow these rules:
`user_access`, `user_moderation`, `identity`, and `role_management`. `user_access`, `user_moderation`, `identity`, and `role_management`.
Actions describe the exact event in namespaced past tense, such as Actions describe the exact event in namespaced past tense, such as
`weapon.updated`, `attribute.archived`, or `moderation.blocked`. `weapon.updated`, `attribute.archived`, or `moderation.blocked`.
4. Capture relationship changes as well as the primary row. When an action 4. Capture changes to the primary row and its relationships. When an action
replaces permission sets, compatible brands, talents, effects, tiers, or replaces permission sets, compatible brands, talents, effects, tiers, or
other linked records, include the meaningful previous and resulting sets. other linked records, include the meaningful previous and resulting sets.
5. Create audit records only after validation succeeds and the requested 5. Create audit records only after validation succeeds and the requested
@@ -122,8 +156,8 @@ Treat embedded database files as persistent user data, not disposable build
artifacts. artifacts.
Before deleting, replacing, recreating, renaming, or destructively migrating a Before deleting, replacing, recreating, renaming, or destructively migrating a
database—and before any major schema change—create a timestamped JSON backup database, create a timestamped JSON backup with `npm run db:backup`. Create the
with `npm run db:backup`. same backup before any major schema change.
Database work must follow these rules: Database work must follow these rules:
+40 -43
View File
@@ -114,17 +114,16 @@ components:
**Creative North Star: "The Strategic Operations Grid"** **Creative North Star: "The Strategic Operations Grid"**
SHD.build behaves like a dependable intelligence network shared by active SHD.build serves active agents as a dependable intelligence network: precise,
agents: precise, restrained, tactical, and structured around trustworthy data. restrained, tactical, and built around trustworthy data. Alignment, density,
The interface communicates through alignment, density, operational labels, and operational labels, and visible state carry the interface. Public surfaces use
visible state rather than spectacle. Public surfaces can breathe and use larger space and larger editorial hierarchy; ISAC uses the same identity in an
editorial hierarchy, while ISAC compresses the same identity into an efficient efficient control surface.
control surface.
The world is technical without becoming sterile. Fine structural lines, Fine structural lines, layered black and graphite surfaces, cropped game
tonally layered black and graphite surfaces, cropped game iconography, clipped iconography, clipped silhouettes, and tactile state changes give the interface
silhouettes, and tactile state changes create its character. Theme language its technical character. Theme language supports comprehension and avoids
supports comprehension; it never becomes military cosplay. military cosplay.
**Key Characteristics:** **Key Characteristics:**
@@ -137,15 +136,15 @@ supports comprehension; it never becomes military cosplay.
## Colors ## Colors
The palette combines Operations Black, Terminal Graphite, Field Manual White, The palette uses Operations Black, Terminal Graphite, Field Manual White, rare
and rare Signal Orange with semantic system and item-quality signals. Signal Orange, and semantic system and item-quality signals.
### Primary ### Primary
- **Signal Orange:** Primary actions, current navigation, focus, selected - **Signal Orange:** Primary actions, current navigation, focus, selected
controls, important values, and SHD identity details. controls, important values, and SHD identity details.
- **Signal Orange Hover:** Brighter confirmation that an orange action is - **Signal Orange Hover:** Confirms that an orange action accepts input; never
interactive, never a second decorative accent. use it as a decorative accent.
### Secondary ### Secondary
@@ -173,11 +172,11 @@ and rare Signal Orange with semantic system and item-quality signals.
- **Telemetry Muted / Telemetry Dim:** Secondary labels and low-priority - **Telemetry Muted / Telemetry Dim:** Secondary labels and low-priority
operational metadata. operational metadata.
**The Orange Discipline Rule.** Signal Orange identifies action and emphasis; **The Orange Discipline Rule.** Use Signal Orange for action and emphasis. Keep
it does not cover large decorative areas or compete with semantic state colors. it out of large decorative areas and away from semantic state colors.
**The Redundant Meaning Rule.** Color always travels with text, position, shape, **The Redundant Meaning Rule.** Pair color with text, position, shape, or an
or an icon. No status or quality can depend on hue alone. icon. A status or quality cannot depend on hue alone.
## Typography ## Typography
@@ -187,11 +186,10 @@ sans-serif fallbacks)
sans-serif fallbacks) sans-serif fallbacks)
**Editorial Font:** Source Serif 4 (with Georgia and serif fallbacks) **Editorial Font:** Source Serif 4 (with Georgia and serif fallbacks)
**Character:** Barlow gives the system voice a squared, compact, engineered **Character:** Barlow gives the system a squared, compact, engineered voice
rhythm while remaining highly readable in dense interfaces. Source Serif 4 is that remains readable in dense interfaces. Reserve Source Serif 4 for
reserved for human-readable explanations, guide-like prose, and selected explanations, guide-like prose, and selected descriptive passages. Keep
descriptive passages so operational data and editorial knowledge remain operational data distinct from editorial knowledge.
visibly distinct.
### Hierarchy ### Hierarchy
@@ -208,20 +206,19 @@ visibly distinct.
- **Label** (900, 0.625rem, 0.1em tracking): Uppercase telemetry, field labels, - **Label** (900, 0.625rem, 0.1em tracking): Uppercase telemetry, field labels,
statuses, metadata, and compact actions. statuses, metadata, and compact actions.
**The Two-Voice Rule.** Sans-serif operates the system; serif explains it. **The Two-Voice Rule.** Sans-serif operates the system; serif explains it. Do
Never use monospace merely to make an interface look technical. not use monospace to make an interface look technical.
**The Telemetry Restraint Rule.** Uppercase tracked labels are short navigational **The Telemetry Restraint Rule.** Uppercase tracked labels are short navigational
or state-bearing signals, not paragraph typography. or state-bearing signals, not paragraph typography.
## Layout ## Layout
The system is an aligned grid of operational regions rather than a collection The system uses an aligned grid of operational regions. Public pages use
of floating cards. Public pages use centered containers up to 1640px with centered containers up to 1640px with responsive outer padding. ISAC uses a
responsive outer padding. ISAC uses a compact command header, sticky 210px compact command header, sticky 210px module rail, and flexible registry
module rail, and a flexible registry workspace. Builder and account surfaces workspace. Builder and account surfaces use workbench columns that collapse
use deliberate workbench columns that collapse into a task-appropriate mobile into a task-appropriate mobile sequence.
sequence.
Spacing follows a tight 5/8/12/20px rhythm with 24px as the standard panel Spacing follows a tight 5/8/12/20px rhythm with 24px as the standard panel
inset. Related controls stay close; modules and major sections receive clearer inset. Related controls stay close; modules and major sections receive clearer
@@ -235,8 +232,8 @@ touch-oriented stacks or controlled horizontal registries. Interactive mobile
targets are at least 44px where space permits. targets are at least 44px where space permits.
Large displays use the shared display-scale progression: 1.15 at 2200px, 1.3 at Large displays use the shared display-scale progression: 1.15 at 2200px, 1.3 at
2800px, and 1.5 at 3400px. This preserves readable density rather than simply 2800px, and 1.5 at 3400px. The scale preserves readable density and avoids
expanding empty space. empty space.
**The Connected Grid Rule.** Use borders, shared edges, and aligned tracks to **The Connected Grid Rule.** Use borders, shared edges, and aligned tracks to
show relationships. Do not wrap every subsection in another card. show relationships. Do not wrap every subsection in another card.
@@ -248,9 +245,9 @@ workbenches prioritize scanability and efficient repeated actions.
SHD.build uses structural layering. Depth comes from tonal surface changes, SHD.build uses structural layering. Depth comes from tonal surface changes,
fine borders, clipped corners, inset active rails, and controlled overlays. fine borders, clipped corners, inset active rails, and controlled overlays.
Resting cards and panels do not float. Shadows are reserved for temporary Resting cards and panels do not float. Use shadows for temporary surfaces that
surfaces that must detach from the document—menus, dialogs, toasts, and routed detach from the document: menus, dialogs, toasts, and routed fault consoles.
fault consoles—and use a clear downward offset with a soft black blur. Give each a clear downward offset and soft black blur.
### Shadow Vocabulary ### Shadow Vocabulary
@@ -273,21 +270,21 @@ dialogs, and navigation elements use square engineered geometry with one-pixel
structural borders. Select hero visuals and status consoles may use clipped structural borders. Select hero visuals and status consoles may use clipped
polygon corners when the silhouette reinforces an operational artifact. polygon corners when the silhouette reinforces an operational artifact.
Circular geometry is identity-specific: agent avatars, the SHD mark, compact Circular geometry identifies agent avatars, the SHD mark, compact online
online indicators, and true radial or orbital data graphics. Pills are not a indicators, and radial or orbital data graphics. Do not use pills as a general
general control shape. control shape.
**The Engineered Edge Rule.** Square is the default and rounding must have **The Engineered Edge Rule.** Square is the default and rounding must have
semantic justification. semantic justification.
**The Identity Circle Rule.** Circular crops belong to people, the SHD emblem, **The Identity Circle Rule.** Circular crops belong to people, the SHD emblem,
and genuinely radial information—not arbitrary controls. and radial information, not arbitrary controls.
## Components ## Components
Components feel tactile and technical: a press produces immediate physical Components feel tactile and technical. A press produces physical feedback; a
feedback, a request exposes pending text and disables repeat activation, and request exposes pending text and blocks repeat activation; focus remains clear
focus is unmistakable without becoming ornamental. without ornament.
### Buttons ### Buttons
+47 -56
View File
@@ -8,44 +8,40 @@ web
## Users ## Users
SHD.build primarily serves active *The Division Resurgence* players who want SHD.build serves active *The Division Resurgence* players who need reliable game
reliable game data, accurate loadout planning, build sharing, and community data, accurate loadout planning, build sharing, and community guides.
guides.
Registered players can save and share builds, publish guides, participate in Registered players can save and share builds, publish guides, participate in
community interactions, and manage their identity and notifications. Anonymous community interactions, and manage their identity and notifications. Anonymous
players can explore public content and keep local builder drafts. players can explore public content and keep local builder drafts.
Trusted contributors maintain structured game data through ISAC. Oversight Trusted contributors maintain structured game data through ISAC. Oversight
reviewers validate proposed changes, moderators handle community safety, and reviewers validate proposed changes. Moderators handle community safety, while
administrators manage users, roles, system configuration, and operations. administrators manage users, roles, system configuration, and operations.
## Product Purpose ## Product Purpose
SHD.build is an independent community intelligence platform for *The Division SHD.build is an independent community intelligence platform for *The Division
Resurgence*. It brings together a public game database, calculated loadout Resurgence*. It includes a public game database, calculated loadout builder,
builder, shared builds, detailed guides, news, and community participation. shared builds, guides, news, and community participation.
Success means players can confidently understand game systems, create accurate Players should understand game systems, create accurate loadouts, learn from
loadouts, learn from other agents, and help keep information current as the game other agents, and help keep information current as the game changes.
changes.
## Positioning ## Positioning
SHD.build uses one community-maintained, structured source of game data across SHD.build uses one community-maintained, structured game-data source for its
its public database, builder calculations, builds, guides, and contribution public database, builder calculations, builds, guides, and contribution
workflow. ISAC lets non-technical contributors propose connected data changes workflow. ISAC lets non-technical contributors propose connected data changes.
while Oversight reviews them before they become authoritative. Oversight reviews proposals before they become authoritative.
This makes SHD.build more than a loadout tool or static wiki: player-facing Player-facing planning and reference experiences use the same moderated data.
planning and reference experiences remain connected to the same moderated data.
## Operating Context ## Operating Context
Players use the site while planning characters, comparing drops, reproducing Players use the site to plan characters, compare drops, reproduce in-game
in-game equipment, browsing community recommendations, and responding to game equipment, browse community recommendations, and respond to game updates. The
updates. The interface must work from mobile devices through high-resolution interface must work from mobile devices through high-resolution desktop displays.
desktop displays.
The principal public channels are News, Database, Builds, Guides, and Builder. The principal public channels are News, Database, Builds, Guides, and Builder.
Account management includes authentication methods, profile identity, avatars, Account management includes authentication methods, profile identity, avatars,
@@ -53,16 +49,15 @@ notifications, created content, favorites, and privacy controls.
ISAC is the operational console for game-data entry, contribution review, user ISAC is the operational console for game-data entry, contribution review, user
and role management, moderation, audit history, automated messages, and system and role management, moderation, audit history, automated messages, and system
metrics. Registered agents begin with read-only access and may explicitly metrics. Registered agents begin with read-only access and can enable
enable contributor mode after accepting the contributor guidelines. Proposed contributor mode after accepting the contributor guidelines. Contributors can
records may depend on other pending records so contributors can build connected connect proposed records to pending records before approval.
data chains before approval.
## Capabilities and Constraints ## Capabilities and Constraints
- The application is built with SvelteKit and Svelte. - The application is built with SvelteKit and Svelte.
- Persistent application data uses the embedded `@tursodatabase/database` - Persistent application data uses the embedded `@tursodatabase/database`
package rather than Turso Cloud. package. Turso Cloud is not required.
- Development, staging, and production use distinct databases. Embedded - Development, staging, and production use distinct databases. Embedded
database files and uploaded assets are persistent user data and must survive database files and uploaded assets are persistent user data and must survive
deployments and additive schema migrations. deployments and additive schema migrations.
@@ -73,9 +68,9 @@ data chains before approval.
requires an account. requires an account.
- Saved builds support private, unlisted, and public visibility. - Saved builds support private, unlisted, and public visibility.
- User deletion is soft deletion so authored builds and guides can remain while - User deletion is soft deletion so authored builds and guides can remain while
login identities and reusable usernames are released safely. the system releases login identities and reusable usernames safely.
- User-uploaded avatars are normalized for storage and enter an Oversight queue - The application normalizes user-uploaded avatars for storage and sends them to
without blocking immediate use. an Oversight queue without blocking immediate use.
- ISAC mutations are permission-controlled, append-only audited, and use - ISAC mutations are permission-controlled, append-only audited, and use
moderation proposals when the operator lacks direct-write authority. moderation proposals when the operator lacks direct-write authority.
- Game-data editors must preserve the standardized add/edit lifecycle described - Game-data editors must preserve the standardized add/edit lifecycle described
@@ -88,14 +83,12 @@ data chains before approval.
## Brand Commitments ## Brand Commitments
The product name is `SHD.build`, with `SHD` always capitalized. The product is The product name is `SHD.build`, with `SHD` capitalized. The product serves the
positioned as a complete *The Division Resurgence* community rather than only a full *The Division Resurgence* community, including loadout planning.
loadout network.
The established voice uses restrained in-universe operational language such as Use restrained in-universe operational language such as agent, ISAC, Oversight,
agent, ISAC, Oversight, intelligence, access, transmission, and registry when intelligence, access, transmission, and registry when it improves comprehension.
it improves comprehension. Theme must never obscure plain meaning or turn into Do not let the theme obscure plain meaning or turn into military cosplay.
military cosplay.
The existing SHD logo, favicon, game-type icon library, quality colors, and The existing SHD logo, favicon, game-type icon library, quality colors, and
provider branding are maintained assets. Discord, Google, Apple, and Steam provider branding are maintained assets. Discord, Google, Apple, and Steam
@@ -103,8 +96,7 @@ identity controls must follow their respective provider requirements.
## Evidence on Hand ## Evidence on Hand
- `docs/DESIGN_SYSTEM.md` is the maintained authority for the incumbent visual - `docs/DESIGN_SYSTEM.md` defines the current visual and interaction language.
and interaction language.
- `docs/architecture.md` documents the application architecture and staged - `docs/architecture.md` documents the application architecture and staged
feature model. feature model.
- `docs/STAT_LEVEL_SCALING.md` contains empirically derived equipment-scaling - `docs/STAT_LEVEL_SCALING.md` contains empirically derived equipment-scaling
@@ -117,35 +109,34 @@ identity controls must follow their respective provider requirements.
category icons, attribute icons, specialization icons, and shared fallback category icons, attribute icons, specialization icons, and shared fallback
artwork. artwork.
- The embedded development database contains contributor-entered game and user - The embedded development database contains contributor-entered game and user
data. It is evidence and persistent user data, not a disposable fixture. data. Treat it as evidence and persistent user data rather than a disposable
fixture.
- No testimonials, player-count claims, official Ubisoft endorsement, or - No testimonials, player-count claims, official Ubisoft endorsement, or
performance benchmarks are established; future work must not fabricate them. performance benchmarks are established; future work must not fabricate them.
## Product Principles ## Product Principles
1. **One source of operational truth.** Public reference data, builder 1. **One source of operational truth.** Keep public reference data, builder
calculations, and contributed updates must remain connected rather than calculations, and contributed updates connected.
becoming divergent copies. 2. **Make deep systems understandable.** Present dense game mechanics with
2. **Make deep systems understandable.** Dense game mechanics should be precision without requiring users to understand the underlying data model.
explorable and precise without requiring users to understand the underlying 3. **Contribution without technical gatekeeping.** Let players improve the
data model. database through clear forms, dependencies, review status, and actionable
3. **Contribution without technical gatekeeping.** Players should be able to Oversight feedback.
improve the database through clear forms, dependencies, review status, and 4. **Trust through visible governance.** Use permissions, moderation, audit
actionable Oversight feedback. history, privacy, and system messages to make consequential states and
4. **Trust through visible governance.** Permissions, moderation, audit
history, privacy, and system messages should make consequential states and
actions explicit. actions explicit.
5. **Preserve the community's work.** User content, contributor-entered data, 5. **Preserve the community's work.** Keep user content, contributor-entered
and operational history must survive deployments, migrations, archival, and data, and operational history through deployments, migrations, archival, and
account lifecycle changes whenever policy permits. account lifecycle changes when policy permits.
## Accessibility & Inclusion ## Accessibility & Inclusion
SHD.build targets WCAG 2.2 AA. Interfaces must support keyboard navigation, SHD.build targets WCAG 2.2 AA. Interfaces must support keyboard navigation,
clear focus-visible states, screen-reader semantics, sufficient contrast, clear focus-visible states, screen-reader semantics, sufficient contrast,
reduced-motion preferences, and layouts that remain functional from mobile reduced-motion preferences, and layouts that work from mobile screens through
screens through 4K desktop displays. 4K desktop displays.
State and meaning must never depend on color, icons, hover, or motion alone. State and meaning cannot depend on color, icons, hover, or motion alone.
Controls need usable touch targets, and user identity must support non-ASCII Controls need usable touch targets. User identity must support non-ASCII display
display names and diverse avatar representation. names and diverse avatar representation.
+44 -46
View File
@@ -1,13 +1,15 @@
# SHD.build # SHD.build
A SvelteKit application for creating and sharing loadouts for *The Division Resurgence*. A SvelteKit community application for *The Division Resurgence*. SHD.build
combines a public game database, loadout builder, shared builds, guides,
account management, and the ISAC data-management console.
The first increment includes a responsive discovery page, an anonymous builder draft saved to local storage, authentication UI boundaries, an admin data-management preview, and an embedded Turso database foundation. See `docs/architecture.md` for the staged implementation plan. See `docs/architecture.md` for the implementation model.
## Database ## Database
The server uses the in-process `@tursodatabase/database` package with a local The server uses the in-process `@tursodatabase/database` package and a local
SQLite-compatible file—there is no Turso Cloud dependency. SQLite-compatible file. Turso Cloud is not required.
Create or update the local database: Create or update the local database:
@@ -15,10 +17,10 @@ Create or update the local database:
npm run db:migrate npm run db:migrate
``` ```
The default file is `data/shd-build.db`. Copy `.env.example` to `.env` to The default file is `data/shd-build.db`. Copy `.env.example` to `.env` and
override its location. set a different location if needed.
Custom avatars are stored as metadata-free 256 × 256 WebP files. Set Store custom avatars as metadata-free 256 × 256 WebP files. Set
`TDR_AVATAR_STORAGE_PATH` to a persistent, writable directory in production; `TDR_AVATAR_STORAGE_PATH` to a persistent, writable directory in production;
it defaults to `data/avatars`. The same configuration works on Windows and it defaults to `data/avatars`. The same configuration works on Windows and
Linux. Linux.
@@ -29,9 +31,9 @@ Seed the initial local accounts once:
npm run db:seed-users npm run db:seed-users
``` ```
This creates administrator `Aewyn` and standard user `Quartermaster`. Strong The seed creates administrator `Aewyn` and standard user `Quartermaster`.
temporary passwords are generated and displayed once; only salted Argon2id It generates and displays strong temporary passwords once, then stores only
hashes are stored. salted Argon2id hashes.
## Develop ## Develop
@@ -41,9 +43,9 @@ npm run dev
## Encrypted environment files ## Encrypted environment files
Development, staging, and production settings live in the committed, encrypted Keep development, staging, and production settings in the committed, encrypted
`.env.development`, `.env.staging`, and `.env.production` files. Private `.env.development`, `.env.staging`, and `.env.production` files. Keep private
decryption keys live only in the ignored `.env.keys` file; back it up in a decryption keys only in the ignored `.env.keys` file and back it up in a
password manager. password manager.
```sh ```sh
@@ -56,42 +58,38 @@ npm run env:decrypt:production
npm run env:sync:staging-credentials # copy shared Discord/SMTP values from development npm run env:sync:staging-credentials # copy shared Discord/SMTP values from development
``` ```
The repository pre-commit hook runs `env:stage`, so a commit always replaces The repository pre-commit hook runs `env:stage` and replaces staged plaintext
staged plaintext environment files with encrypted versions. Git does not expose environment files with encrypted versions. Git has no pre-add hook. Use
a pre-add hook, so use `npm run env:stage` instead of directly adding these two `npm run env:stage` instead of directly adding these two files.
files when possible.
Never commit or lose `.env.keys`. Production should receive Keep `.env.keys` out of Git and retain a backup. Production deployments receive
`DOTENV_PRIVATE_KEY_PRODUCTION` through its secret manager rather than copying `DOTENV_PRIVATE_KEY_PRODUCTION` through their secret manager; do not copy the
the keys file onto the server. keys file to the server.
## Containers and staging deployment ## Containers and staging deployment
The application uses the official SvelteKit Node adapter and includes a The application uses the official SvelteKit Node adapter and a multi-stage
multi-stage `Dockerfile`. Database files, database backups, user avatars, and `Dockerfile`. Keep database files, database backups, user avatars, and uploaded
uploaded game-data icons live under `/app/data`, which must be a persistent game-data icons under `/app/data` on a persistent volume. Inject runtime secrets
volume. Runtime secrets are injected when the container starts and are never when the container starts; keep them out of the image.
baked into the image.
The staging compose topology and Gitea Actions deployment are documented in [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) documents the staging Compose topology
[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md). Pushes to `main` validate the and Gitea Actions deployment. A push to `main` validates the application,
application, build the candidate image while staging remains online, stop the builds the candidate image while staging stays online, stops the application
application and scheduler briefly, back up the embedded database, migrate it, and scheduler for the database backup and migration, then starts the release.
and start the new release.
## Passwordless authentication ## Passwordless authentication
Passkeys are the preferred sign-in method. `APP_ORIGIN` must exactly match the Use passkeys as the preferred sign-in method. Set `APP_ORIGIN` to the exact
public origin used for registration and authentication, while `PASSKEY_RP_ID` public origin for registration and authentication. Set `PASSKEY_RP_ID` to that
must be that hostname or a registrable parent domain. The hosted environments hostname or a registrable parent domain. Hosted environments use `shd.build`,
use `shd.build`, allowing credentials on both `shd.build` and which permits credentials on `shd.build` and `dev.shd.build`. Use `localhost`
`dev.shd.build`. Direct localhost development should use `localhost`. for direct local development.
Magic links require `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE`, `SMTP_USER`, Magic links require `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE`, `SMTP_USER`,
`SMTP_PASSWORD`, and `SMTP_FROM`. Port 465 normally uses `SMTP_PASSWORD`, and `SMTP_FROM`. Use `SMTP_SECURE=true` on port 465. Use
`SMTP_SECURE=true`; port 587 normally uses `false` and upgrades with STARTTLS. `false` on port 587 and upgrade with STARTTLS. An account owner activates an
Email identities become usable only after the account owner follows a email identity by following a 15-minute, single-use verification link.
15-minute, single-use verification link.
## Validate ## Validate
@@ -100,12 +98,10 @@ npm run check
npm run build npm run build
``` ```
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). [`sv`](https://github.com/sveltejs/cli) creates and configures Svelte projects.
## Creating a project ## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh ```sh
# create a new project # create a new project
npx sv create my-app npx sv create my-app
@@ -120,7 +116,8 @@ npx sv@0.16.5 create --template minimal --types ts --install npm .
## Developing ## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: Install dependencies with `npm install` (or `pnpm install` or `yarn`), then
start a development server:
```sh ```sh
npm run dev npm run dev
@@ -131,15 +128,16 @@ npm run dev -- --open
## Building ## Building
To create a production version of your app: Build a production version:
```sh ```sh
npm run build npm run build
``` ```
You can preview the production build with `npm run preview`. Preview the production build with `npm run preview`.
> Deployments may require an [adapter](https://svelte.dev/docs/kit/adapters) for the target environment.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
The shared visual language and dashboard conventions are documented in The shared visual language and dashboard conventions are documented in
[docs/DESIGN_SYSTEM.md](docs/DESIGN_SYSTEM.md). [docs/DESIGN_SYSTEM.md](docs/DESIGN_SYSTEM.md).
+2
View File
@@ -620,11 +620,13 @@ CREATE TABLE IF NOT EXISTS os_protocol_talent_effects (
CREATE TABLE IF NOT EXISTS skill_chip_sets ( CREATE TABLE IF NOT EXISTS skill_chip_sets (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
specialization_ability_id TEXT REFERENCES specialization_abilities(id),
active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0, 1)), active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0, 1)),
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
CREATE UNIQUE INDEX IF NOT EXISTS skill_chip_sets_name_nocase_idx ON skill_chip_sets(name COLLATE NOCASE); CREATE UNIQUE INDEX IF NOT EXISTS skill_chip_sets_name_nocase_idx ON skill_chip_sets(name COLLATE NOCASE);
CREATE INDEX IF NOT EXISTS skill_chip_sets_ability_idx ON skill_chip_sets(specialization_ability_id,active);
CREATE TABLE IF NOT EXISTS skill_chip_two_piece_bonuses ( CREATE TABLE IF NOT EXISTS skill_chip_two_piece_bonuses (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
skill_chip_set_id TEXT NOT NULL UNIQUE REFERENCES skill_chip_sets(id) ON DELETE CASCADE, skill_chip_set_id TEXT NOT NULL UNIQUE REFERENCES skill_chip_sets(id) ON DELETE CASCADE,
+40 -44
View File
@@ -1,9 +1,9 @@
# Container, staging, and production deployment # Container, staging, and production deployment
SHD.build is packaged as a Node 24 container using the official SvelteKit Node SHD.build runs in a Node 24 container through the official SvelteKit Node
adapter. The image contains application code, migration and notification adapter. The image includes application code, migration and notification
scripts, and native runtime dependencies. It does not contain an environment scripts, and native runtime dependencies. Environment files, databases,
file, database, backups, or user uploads. backups, and user uploads remain outside the image.
## Persistent data ## Persistent data
@@ -15,10 +15,9 @@ mounted at `/app/data`. It contains:
- normalized user avatars under `avatars/`; - normalized user avatars under `avatars/`;
- uploaded game-data icons under `game-icons/`. - uploaded game-data icons under `game-icons/`.
Never remove this volume during routine deployments. `docker compose down` Keep this volume during routine deployments. `docker compose down` without
without `--volumes` preserves it. Before any manual backup or restore, stop `--volumes` preserves it. Before a manual backup or restore, stop the `app`
both the `app` and `scheduler` services because both access the embedded and `scheduler` services because both access the embedded database.
database.
## Staging environment ## Staging environment
@@ -46,9 +45,9 @@ port on the host interfaces so a containerized reverse proxy or tunnel can
reach it; firewall the port to trusted LAN/tunnel sources when the host is reach it; firewall the port to trusted LAN/tunnel sources when the host is
reachable from other networks. reachable from other networks.
The deployment decrypts `.env.staging` on the trusted staging runner into a The trusted staging runner decrypts `.env.staging` into a mode-`0600` runtime
mode-`0600` runtime file. The decryption key is held by Gitea Actions, not file. Gitea Actions holds the decryption key; Git and the container image do
committed or included in the container image. not.
The staging container disables default-user seeding. On a brand-new staging The staging container disables default-user seeding. On a brand-new staging
database, the existing bootstrap policy grants Admin to the first real account database, the existing bootstrap policy grants Admin to the first real account
@@ -93,9 +92,9 @@ In `https://git.jb.codes/jb/shd.build/settings/actions`:
3. Optionally add repository variable `STAGE_PORT`. It defaults to `5569`. 3. Optionally add repository variable `STAGE_PORT`. It defaults to `5569`.
4. Ensure Actions are enabled and the `stage-deploy` runner is online. 4. Ensure Actions are enabled and the `stage-deploy` runner is online.
The workflow at `.gitea/workflows/deploy-staging.yml` runs on every push to `.gitea/workflows/deploy-staging.yml` runs for pushes to `main` and manual
`main` and can also be started manually. It does not need registry credentials dispatches. The trusted staging runner builds each commit-tagged image locally,
because the trusted staging runner builds a commit-tagged image locally. so the workflow needs no registry credentials.
## Deployment lifecycle ## Deployment lifecycle
@@ -110,8 +109,7 @@ The workflow:
7. waits for `/api/health/database` to report healthy; 7. waits for `/api/health/database` to report healthy;
8. retains the logs and exits unsuccessfully if health verification fails. 8. retains the logs and exits unsuccessfully if health verification fails.
The short outage is limited to backup, migration, and container startup. This The outage covers backup, migration, and container startup.
follows the database-preservation policy while minimizing data-entry downtime.
The scheduler calls a bearer-authenticated endpoint on the application service The scheduler calls a bearer-authenticated endpoint on the application service
once per minute. Scheduled message delivery therefore uses the web process's once per minute. Scheduled message delivery therefore uses the web process's
existing embedded-database connection instead of opening the locked database existing embedded-database connection instead of opening the locked database
@@ -120,11 +118,10 @@ consolidation, and email digest checks and is not published through a host port.
## Initial development-data bootstrap ## Initial development-data bootstrap
The first staging release can be initialized from the current development Initialize the first staging release from the development database, including
database, including users, roles, builds, guides, proposals, audit history, users, roles, builds, guides, proposals, audit history, avatars, and uploaded
avatars, and uploaded game-data icons. This is deliberately a one-time flow: game-data icons. The staging importer refuses a target database that contains
the staging importer refuses to run after users, builds, audits, or proposals users, builds, audits, or proposals.
exist in its target database.
First stop this project's development server. The embedded database must not be First stop this project's development server. The embedded database must not be
open while its complete transfer bundle is created: open while its complete transfer bundle is created:
@@ -134,8 +131,8 @@ npm run stage:bootstrap:export
``` ```
The command writes a timestamped, ignored JSON bundle under `data/backups/`. The command writes a timestamped, ignored JSON bundle under `data/backups/`.
It contains user records and password hashes, so transfer it over an encrypted The bundle includes user records and password hashes. Transfer it over an
channel and never add it to Git. Copy it to the staging host, then run: encrypted channel, keep it out of Git, then copy it to the staging host and run:
```sh ```sh
/opt/shd-build-stage/bootstrap-staging.sh \ /opt/shd-build-stage/bootstrap-staging.sh \
@@ -160,8 +157,8 @@ Actions. It briefly stops staging database writers, exports only canonical
builder-source tables plus uploaded game-data icons, restarts staging, and builder-source tables plus uploaded game-data icons, restarts staging, and
publishes a `shd-stage-isac-data` artifact for 14 days. publishes a `shd-stage-isac-data` artifact for 14 days.
Download `isac-stage.json` from that workflow run. Then stop this project's Download `isac-stage.json` from that workflow run, stop this project's
development server and run: development server, and run:
```sh ```sh
npm run isac:import -- /path/to/isac-stage.json --confirm-replace-isac-data npm run isac:import -- /path/to/isac-stage.json --confirm-replace-isac-data
@@ -206,10 +203,10 @@ environment, port, and deployment workflow:
- Database: `/app/data/shd-build-production.db` - Database: `/app/data/shd-build-production.db`
- Persistent volume: `shd-build-production-data` - Persistent volume: `shd-build-production-data`
Routine pushes never import or replace production data. The production deploy Routine pushes leave production data unchanged. The production deploy stops its
stops only its application and scheduler, creates a timestamped JSON backup, application and scheduler, creates a timestamped JSON backup, applies repeatable
applies repeatable migrations, restarts, and verifies database health. Never migrations, restarts, and verifies database health. Keep
run `docker compose down --volumes` against production. `docker compose down --volumes` away from production.
Add these repository Action secrets: Add these repository Action secrets:
@@ -234,8 +231,8 @@ reuse the staging scheduler token.
### Initial production bootstrap ### Initial production bootstrap
The one-time production bootstrap clones current development data and uploads. The production bootstrap clones current development data and uploads. Stop this
Stop this project's development server before creating the bundle: project's development server before creating the bundle:
```sh ```sh
npm run production:bootstrap:export npm run production:bootstrap:export
@@ -269,9 +266,8 @@ notifications, settings, and audit history untouched.
Manually run **Replace staging ISAC data from production** in Gitea Actions. Manually run **Replace staging ISAC data from production** in Gitea Actions.
The workflow exports a consistent production snapshot, restarts production, The workflow exports a consistent production snapshot, restarts production,
then stops staging and imports it. The staging importer creates both full and then stops staging and imports it. The staging importer creates full and
ISAC-only safety backups before replacement. This is intentionally manual and ISAC-only safety backups before replacement. Deployments do not run this flow.
never runs as part of either deployment.
### Promote managed images without replacing other data ### Promote managed images without replacing other data
@@ -280,9 +276,9 @@ development. Export a bundle after finishing local uploads and metadata edits:
Set the server-only `REMOVE_BG_API_KEY` in each encrypted environment that Set the server-only `REMOVE_BG_API_KEY` in each encrypted environment that
accepts ISAC uploads. It is sent only from the server in the `X-Api-Key` header. accepts ISAC uploads. It is sent only from the server in the `X-Api-Key` header.
Icon uploads that already contain transparency do not call remove.bg; opaque Transparent icon uploads skip remove.bg. Opaque sources use its preview output,
icon sources use its preview output and then continue through local trimming, then pass through local trimming, white normalization, WebP conversion, and
white normalization, WebP conversion, and storage. storage.
```sh ```sh
npm run images:export npm run images:export
@@ -311,13 +307,13 @@ docker compose -f compose.production.yml run --rm \
docker compose -f compose.production.yml up -d app scheduler docker compose -f compose.production.yml up -d app scheduler
``` ```
Each import creates a full target database backup before changing anything. Each import creates a full target database backup before changes. The importer
New stable IDs are inserted, identical existing assets are skipped, and a adds new stable IDs, skips identical assets, and blocks conflicting records.
different existing record blocks the import. After reviewing an intentional After reviewing an intentional metadata or file replacement, add
metadata or file replacement, add `--update-existing`. Imported changes append `--update-existing`. Imported changes append `image.imported` or
`image.imported` or `image.import_updated` audit events. Remove the transferred `image.import_updated` audit events. Remove the transferred bundle after
bundle after both environments have been verified because it may contain verifying both environments because it may contain unpublished visual assets
unpublished visual assets and creator identifiers. and creator identifiers.
The `.env.staging` and `.env.production` npm import commands are intended for The `.env.staging` and `.env.production` npm import commands are intended for
maintenance performed directly in an environment where those database and maintenance performed directly in an environment where those database and
+92 -92
View File
@@ -2,14 +2,13 @@
This document defines the visual language for SHD.build, the independent This document defines the visual language for SHD.build, the independent
Division Resurgence community for builds, guides, game data, news, and Division Resurgence community for builds, guides, game data, news, and
discussion. New pages should feel like part of the same tactical intelligence discussion. New pages use the same tactical intelligence network as the admin
network as the admin dashboard, while public pages may use more space and larger dashboard. Public pages may use more space and larger editorial typography.
editorial typography.
## Design principles ## Design principles
1. **Tactical, not military cosplay.** Use operational language, compact information density, restrained status indicators, and precise alignment. Avoid camouflage, distressed textures, or decorative weapon imagery in utility interfaces. 1. **Tactical.** Use operational language, compact information density, restrained status indicators, and precise alignment. Avoid camouflage, distressed textures, and decorative weapon imagery in utility interfaces.
2. **Data first.** Hierarchy comes from grouping, typography, borders, and spacing. Admin tools should resemble a dependable control surface rather than a collection of marketing cards. 2. **Data first.** Grouping, typography, borders, and spacing establish hierarchy. Admin tools use a dependable control surface.
3. **Orange means action.** SHD orange identifies primary actions, active navigation, focus, and important values. It should not cover large areas. 3. **Orange means action.** SHD orange identifies primary actions, active navigation, focus, and important values. It should not cover large areas.
4. **State is always visible.** Loading, disabled, active, archived, configured, error, and success states need both visual and textual indicators. 4. **State is always visible.** Loading, disabled, active, archived, configured, error, and success states need both visual and textual indicators.
5. **Square and engineered.** Panels and controls use square corners, fine borders, tight spacing, and deliberate grid alignment. 5. **Square and engineered.** Panels and controls use square corners, fine borders, tight spacing, and deliberate grid alignment.
@@ -20,42 +19,41 @@ editorial typography.
### Routed error console ### Routed error console
Expected HTTP errors and unexpected server faults use one shared SHD exception Expected HTTP errors and unexpected server faults use one shared SHD exception
console inside the normal application shell. The console always displays the console inside the application shell. The console displays the
numeric status, an operational title, a safe explanation, request path, node, numeric status, an operational title, a safe explanation, request path, node,
and context-appropriate recovery controls. Common statuses receive distinct and context-appropriate recovery controls. Common statuses receive distinct
language: authentication for 401, clearance for 403, lost location for 404, language: authentication for 401, clearance for 403, lost location for 404,
conflict for 409, saturation for 429, and service/fault language for 5xx. conflict for 409, saturation for 429, and service/fault language for 5xx.
Orange identifies recoverable request errors and actions; red identifies server Orange identifies recoverable request errors and actions; red identifies server
faults. Never display stack traces, database messages, credentials, request faults. Do not display stack traces, database messages, credentials, request
queries, or raw unexpected exceptions to users. Unexpected server faults receive queries, or raw unexpected exceptions. The global error handler gives each
a short reference ID from the global error handler for support correlation while unexpected server fault a short support reference ID and keeps the full error
the complete error remains server-side. If the root application shell itself server-side. If the root application shell cannot render, `src/error.html`
cannot render, `src/error.html` provides a dependency-free SHD fallback surface. provides a dependency-free SHD fallback surface.
Error controls retain square geometry, focus-visible treatment, pressed motion, Error controls retain square geometry, focus-visible treatment, pressed motion,
plain-language labels, and stacked mobile recovery actions. plain-language labels, and stacked mobile recovery actions.
### Contact relay and legal requests ### Contact relay and legal requests
Public support uses the SHD Contact Relay surface: a broad transmission form Public support uses the SHD Contact Relay: a broad transmission form and a
paired with a compact destination guide. Two-to-six destinations use the shared compact destination guide. Two-to-six destinations use the shared
segmented-choice pattern. Signed-in identity details may be prefilled, but the segmented-choice pattern. Signed-in identity details may be prefilled; people
public form remains available to people who cannot sign in. Discord community who cannot sign in retain access to the public form. Discord community access
access is a distinct provider-colored card; it supplements rather than replaces uses a distinct provider-colored card alongside the durable contact channel.
the durable contact channel.
The Contact hero explicitly resets the global site-header height: semantic page The Contact hero resets the global site-header height: semantic page `header`
`header` elements must use content-driven height and spacing rather than inherit elements use content-driven height and spacing instead of the 76-pixel
the 76-pixel navigation-shell constraint. Its title, description, and Discord navigation-shell constraint. Its title, description, and Discord
card align within a minimum 230-pixel desktop band before the form grid begins. card align within a minimum 230-pixel desktop band before the form grid begins.
The Contact page itself remains content-height-driven so high-resolution browser The Contact page itself remains content-height-driven so high-resolution browser
zoom does not multiply a viewport minimum into a large empty region above the zoom does not multiply a viewport minimum into a large empty region above the
footer. footer.
The application frame is a full-viewport vertical flex stack. The shared footer The application frame uses a full-viewport vertical flex stack. The shared
uses automatic top margin, so it rests against the bottom edge on short routes footer uses automatic top margin. It rests against the bottom edge on short
and follows normal document flow after longer content. Individual pages must routes and follows document flow after longer content. Do not add spacer heights
not add spacer heights solely to position the footer. The frame compensates for only to position the footer. The frame compensates for
`--shd-display-scale` when calculating its viewport minimum so high-resolution `--shd-display-scale` when calculating its viewport minimum so high-resolution
browser zoom does not push a short-page footer below the visible edge. browser zoom does not push a short-page footer below the visible edge.
@@ -73,8 +71,8 @@ color the complete product name orange.
### Community platform positioning ### Community platform positioning
SHD.build is a full Division Resurgence community, not solely a loadout tool. SHD.build is a Division Resurgence community. Primary product language and
Primary product language and global navigation must represent five durable global navigation represent five durable
channels: News, Database, Builds, Guides, and Builder. Loadout-specific terms channels: News, Database, Builds, Guides, and Builder. Loadout-specific terms
remain appropriate inside build routes, but the global brand descriptor, remain appropriate inside build routes, but the global brand descriptor,
homepage metadata, hero, sign-in introduction, and footer describe the broader homepage metadata, hero, sign-in introduction, and footer describe the broader
@@ -211,12 +209,10 @@ The canonical tokens live in `src/app.css` under `:root`.
### High-resolution display scaling ### High-resolution display scaling
The application uses restrained, stepped interface scaling when the effective The application uses restrained stepped interface scaling on wide CSS
CSS viewport is substantially wider than a conventional desktop. At 2200px the viewports. At 2200px, the interface scales to 115%; at 2800px, to 130%; and at
complete interface scales to 115%, at 2800px to 130%, and at 3400px to 150%. 3400px, to 150%. This maintains the established relationship among typography,
This preserves the established relationship between typography, controls, controls, tables, spacing, hit targets, and fixed-position surfaces.
tables, spacing, hit targets, and fixed-position surfaces instead of enlarging
text alone.
Use the shared `--shd-display-scale` token and the root-level `zoom` treatment; Use the shared `--shd-display-scale` token and the root-level `zoom` treatment;
do not add module-specific 4K font-size overrides. Responsive layouts below do not add module-specific 4K font-size overrides. Responsive layouts below
@@ -233,7 +229,7 @@ Use the spacing tokens for new components. Prefer `8px`, `12px`, and `20px` gaps
## Typography ## Typography
SHD.build self-hosts two intentional type voices through Fontsource packages: SHD.build self-hosts two type voices through Fontsource packages:
- **Barlow** is the operational family for display headings, navigation, - **Barlow** is the operational family for display headings, navigation,
controls, data, labels, metadata, and general interface copy. Use the shared controls, data, labels, metadata, and general interface copy. Use the shared
@@ -243,10 +239,9 @@ SHD.build self-hosts two intentional type voices through Fontsource packages:
`--shd-font-editorial`; do not apply it to controls, tables, metrics, or `--shd-font-editorial`; do not apply it to controls, tables, metrics, or
telemetry labels. telemetry labels.
Only the used Barlow weights (400, 600, 700, 800, and 900) and Source Serif 4 Load Barlow weights 400, 600, 700, 800, and 900, plus Source Serif 4 weights
weights (400 and 600) are loaded. Both families are self-hosted, preserve 400 and 600. Both families are self-hosted, keep fallback stacks, and render
fallback stacks, and render immediately without a third-party font request. without a third-party font request. Barlow operates the system; Source Serif 4
The two-voice rule remains semantic: Barlow operates the system; Source Serif 4
explains it. explains it.
- Page and module headings are uppercase, heavy, and tightly spaced. - Page and module headings are uppercase, heavy, and tightly spaced.
@@ -265,7 +260,7 @@ explains it.
### Global mobile navigation ### Global mobile navigation
At viewports up to 900px, replace the hidden desktop link rail and account At viewports up to 900px, replace the hidden desktop link rail and account
controls with a clearly labeled square `MENU` control in the shared site controls with a labeled square `MENU` control in the shared site
header. Activating it opens a right-side SHD navigation panel over a dismissible header. Activating it opens a right-side SHD navigation panel over a dismissible
backdrop. The panel includes Home, Discover, Builder, Guides, Sign In or My backdrop. The panel includes Home, Discover, Builder, Guides, Sign In or My
Profile, and ISAC when authorized; authenticated agents also receive identity Profile, and ISAC when authorized; authenticated agents also receive identity
@@ -289,7 +284,7 @@ squeezed beside long module headings on mobile.
### Public pages ### Public pages
- Maximum content width: approximately 1400–1640px. - Maximum content width: approximately 1400–1640px.
- Larger headings and more generous vertical rhythm are appropriate. - Public pages may use larger headings and more generous vertical rhythm.
- Preserve the grid background and orange accents. - Preserve the grid background and orange accents.
- Catalogue and editorial pages use `catalogue-hero` for an angular introductory - Catalogue and editorial pages use `catalogue-hero` for an angular introductory
panel and may pair it with a compact operational control bar. panel and may pair it with a compact operational control bar.
@@ -377,7 +372,8 @@ squeezed beside long module headings on mobile.
white Apple mark on black, Discord uses its unmodified white symbol on white Apple mark on black, Discord uses its unmodified white symbol on
Blurple, and Steam uses its white linked-circle mark on Steam navy. Blurple, and Steam uses its white linked-circle mark on Steam navy.
- Providers that are not configured are disabled, desaturated, and marked - Providers that are not configured are disabled, desaturated, and marked
`PLANNED`; they must not navigate, submit, or visually imply availability. `PLANNED`; they must not navigate, submit, or imply availability through
styling.
- When each provider is implemented, replace its placeholder with the - When each provider is implemented, replace its placeholder with the
provider-rendered control or current pre-approved sign-in artwork where the provider-rendered control or current pre-approved sign-in artwork where the
provider requires it. In particular, use Google Identity Services or its provider requires it. In particular, use Google Identity Services or its
@@ -560,18 +556,17 @@ Do not communicate state by color alone; include a label, icon, or pattern.
5. Use the shared tokens instead of introducing near-duplicate colors. 5. Use the shared tokens instead of introducing near-duplicate colors.
6. Verify keyboard focus, pending behavior, empty states, mobile layout, and archived data behavior. 6. Verify keyboard focus, pending behavior, empty states, mobile layout, and archived data behavior.
If a new interface needs a genuinely new pattern, document it here when introducing it. Document a new interface pattern here when you introduce it.
## Design-system governance ## Design-system governance
Significant visual and interaction changes must update this document and the Update this document and the shared implementation with each significant visual
shared implementation in the same change. A change is significant when it or interaction change. A change qualifies when it introduces or revises a
introduces or materially revises a reusable token, component pattern, layout, reusable token, component pattern, layout, responsive rule, accessibility
responsive rule, accessibility behavior, motion behavior, or state convention. behavior, motion behavior, or state convention.
Small alignment corrections and isolated visual bug fixes do not require a Small alignment corrections and isolated visual bug fixes need no documentation
documentation change unless they establish a rule that other interfaces should change unless they establish a reusable rule.
follow.
Repository agents and contributors should follow the maintenance checklist in Repository agents and contributors should follow the maintenance checklist in
`AGENTS.md`. `AGENTS.md`.
@@ -753,7 +748,7 @@ numbers.
When only one page exists, omit page navigation and row-count presets; retain When only one page exists, omit page navigation and row-count presets; retain
only the useful result range and any registry search. Do not render disabled only the useful result range and any registry search. Do not render disabled
pagination controls simply to preserve the rail's shape. pagination controls to preserve the rail's shape.
Registries with a meaningful name, title, or agent-identity column place the Registries with a meaningful name, title, or agent-identity column place the
shared server-side search inside this pagination rail. Results update after a shared server-side search inside this pagination rail. Results update after a
@@ -787,13 +782,13 @@ bookmarkable, and expose the selected state with both orange emphasis and
textually identifiable active states. Every card explicitly says `FILTER →` textually identifiable active states. Every card explicitly says `FILTER →`
or `SELECTED`; do not rely on KPI-card styling alone to communicate that the or `SELECTED`; do not rely on KPI-card styling alone to communicate that the
metrics are controls. Counts describe the full search scope, metrics are controls. Counts describe the full search scope,
not merely the visible page. Do not use aggregate facts that cannot filter the not merely the visible page. Replace aggregate facts that cannot filter the
affected registry—replace them with actionable subsets. affected registry with actionable subsets.
### Registry row interaction ### Registry row interaction
Desktop registry rows are passive data containers unless the entire row Desktop registry rows are passive data containers unless the entire row opens
actually opens a detail view. Do not apply a whole-row hover surface when only a detail view. Do not apply a whole-row hover surface when only
the trailing Edit or View action is interactive. Render that action as a the trailing Edit or View action is interactive. Render that action as a
compact bordered control with hover, focus-visible, and pressed states so its compact bordered control with hover, focus-visible, and pressed states so its
interaction boundary is unmistakable. interaction boundary is unmistakable.
@@ -812,7 +807,7 @@ and normalization checks remain authoritative.
### Moderated data states ### Moderated data states
ISAC must clearly distinguish direct editing, contributor proposals, and read-only ISAC must distinguish direct editing, contributor proposals, and read-only
access. Use a compact access banner above the module rather than changing the access. Use a compact access banner above the module rather than changing the
registry layout. Pending proposal counts appear as orange numeric indicators in registry layout. Pending proposal counts appear as orange numeric indicators in
data-navigation items. Proposal lifecycle states use text as well as color: data-navigation items. Proposal lifecycle states use text as well as color:
@@ -1077,7 +1072,7 @@ copy; do not apply the system-transmission frame to agent comments.
Inbox entries expose two distinct actions. `VIEW DETAILS` opens the complete Inbox entries expose two distinct actions. `VIEW DETAILS` opens the complete
message when preview copy is truncated to two lines. A separate bordered orange message when preview copy is truncated to two lines. A separate bordered orange
response action navigates directly to the known workflow and uses specific response action opens the known workflow and uses specific
language such as `UPLOAD NEW AVATAR`, `REVISE CONTRIBUTION`, or language such as `UPLOAD NEW AVATAR`, `REVISE CONTRIBUTION`, or
`VIEW BUILD & REPLY`; never substitute a generic destination label when the `VIEW BUILD & REPLY`; never substitute a generic destination label when the
notification type is known. The same response label appears in the detail notification type is known. The same response label appears in the detail
@@ -1119,20 +1114,18 @@ unfocusable and unchanged.
may omit visible text when a canonical, unambiguous game icon exists; they must may omit visible text when a canonical, unambiguous game icon exists; they must
retain an accessible label and hover title. Text-only segments may omit the retain an accessible label and hover title. Text-only segments may omit the
default square indicator when the selected background, orange text, and lower default square indicator when the selected background, orange text, and lower
rail already communicate state clearly. Do not remove both visible text and an rail communicate state. Do not remove both visible text and an
icon from the same option. icon from the same option.
### Skill Chip data console ### Skill Chip data console
Skill Chip editing follows the dense ISAC staged-definition pattern: identity Skill Chip editing uses the dense ISAC staged-definition pattern: identity and
and icon first, the 2-piece numeric bonus second, mutually exclusive primary icon, 2-piece numeric bonus, mutually exclusive primary and secondary roll
and secondary roll pools next, and the 3-piece talent last. Superior blue and pools, then 3-piece talent. Superior blue and High-end yellow identify the
High-end yellow communicate the only supported qualities; orange remains supported qualities; reserve orange for actions and the current interactive
reserved for actions and current interactive state. A four-step segmented state. A four-step segmented preview shows the linear zero-to-three High-end
preview exposes the linear zero-to-three High-end interpolation without storing interpolation without duplicate values. At narrow widths, stack roll-pool and
duplicate values. At narrow widths the roll pools and talent columns stack, talent columns, use a two-by-two preview grid, and retain 44-pixel targets.
preview segments become a two-by-two grid, and interactive targets remain at
least 44 pixels tall.
### ISAC record identity controls ### ISAC record identity controls
@@ -1145,12 +1138,12 @@ drop-zone to fill spare identity-row space.
Icon-enabled consecutive-entry editors pass a successful-add result token into Icon-enabled consecutive-entry editors pass a successful-add result token into
the shared upload control. Advancing that token clears the native file input, the shared upload control. Advancing that token clears the native file input,
temporary preview URL, filename, drag/paste feedback, and validation state. temporary preview URL, filename, drag/paste feedback, and validation state.
This reset occurs only after a validated add succeeds; failed saves preserve Reset only after a validated add succeeds. Failed saves preserve the selected
the selected image, and successful edits close the editor normally. image; successful edits close the editor.
The primary `name` field in every ISAC data editor is a header-level input: The primary `name` field in each ISAC data editor is a 46-pixel header-level
46 pixels tall with larger, heavier text. This creates a consistent first input with larger, heavier text. It gives long registries a consistent first
reading point for long registries while keeping secondary metadata compact. reading point while keeping secondary metadata compact.
`SearchableAssignment` is the standard relationship picker for long attribute, `SearchableAssignment` is the standard relationship picker for long attribute,
talent, trigger, and compatibility lists. Multiple mode manages additive sets; talent, trigger, and compatibility lists. Multiple mode manages additive sets;
@@ -1621,9 +1614,9 @@ second. Use a compact angular command band rather than a cinematic display
hero: keep the archive identity, state how many registries are online, state the hero: keep the archive identity, state how many registries are online, state the
record total, and present contribution as a bordered secondary action. record total, and present contribution as a bordered secondary action.
Place one discovery bar directly below the command band. Its label presents Place one discovery bar below the command band. Its label gives users two equal
browsing and global search as equal paths—select a registry or search all paths: select a registry or search all data. Its search field searches across
data—and its search field searches across all public registry names and useful all public registry names and useful
descriptive data such as Talent text, Damage Types, item slots, qualities, and descriptive data such as Talent text, Damage Types, item slots, qualities, and
Brand bonus Attributes. Do not stack a separate registry-heading bar above or Brand bonus Attributes. Do not stack a separate registry-heading bar above or
below it. Results name both the matching record and its registry, then link into below it. Results name both the matching record and its registry, then link into
@@ -1640,12 +1633,11 @@ workflow-centric states are not exposed as primary public labels.
### ISAC progression-tree composer ### ISAC progression-tree composer
Use the Specialization workbench pattern for ordered game systems whose Use the Specialization workbench pattern for ordered game systems that depend
meaning depends on both hierarchy and spatial placement. Keep the registry, on hierarchy and spatial placement. Keep the registry, selected parent identity,
selected parent identity, child navigation, readiness telemetry, and visual child navigation, readiness telemetry, and visual tree in one operational
tree in one operational surface. Editors still open above the registry and surface. Editors open above the registry and follow the standard ISAC lifecycle.
follow the standard ISAC lifecycle; the workbench is navigation and context, Use the workbench for navigation and context.
not a replacement modal.
The tree uses a stable 12-column track per row. Row and column are explicit The tree uses a stable 12-column track per row. Row and column are explicit
numeric fields so placement is keyboard operable, auditable, and portable; numeric fields so placement is keyboard operable, auditable, and portable;
@@ -1662,20 +1654,19 @@ the orange identity rail plus the explicit `1 / 1 · FREE` state; pending,
archived, incomplete, and builder-ready states must remain textual as well as archived, incomplete, and builder-ready states must remain textual as well as
colored. colored.
Treat allocation scope as first-class telemetry. Specialization progression is Show allocation scope as first-class telemetry. A specialization unlocks once;
unlocked once for a specialization, but every focus receives the full point each focus receives its own full point allocation. Show the point total and
allocation independently. Show both the point total and that scope near the scope near the tree. A focus becomes builder-ready after it has three distinct
tree; do not imply that focuses draw from one shared spend pool. A focus is active abilities, each ability has exactly three active Skill Chip definitions,
builder-ready only after it has three distinct active abilities, contiguous tree rows are contiguous, at least one paid node exists, and the tree uses the
tree rows, at least one paid node, and exactly the configured available point configured available point total.
total.
Compose focus identity from the uploaded specialization artwork and the Compose focus identity from uploaded specialization artwork and the canonical
canonical Firepower, Toughness, or Engineering icon. Keep the specialization Firepower, Toughness, or Engineering icon. Keep the specialization art primary
art primary and the attribute mark as a smaller corner badge. Ability slots 1 and place the attribute mark in a smaller corner badge. Mirror ability slots 1
and 3 are mirrored as free tree nodes; slot 2 is labeled Combat Skills-only and and 3 as free tree nodes. Label slot 2 Combat Skills-only and exclude it from
must not appear in the upgrade tree. Universal grenade and medical-kit actions the upgrade tree. Universal grenade and medical-kit actions are outside the
are not specialization abilities. specialization ability set.
Use `StructuredEffectEditor` whenever an ISAC record needs an ordered set of Use `StructuredEffectEditor` whenever an ISAC record needs an ordered set of
calculation-ready effects. It provides Attribute, Ability when applicable, and calculation-ready effects. It provides Attribute, Ability when applicable, and
@@ -1686,6 +1677,15 @@ do not yet support. Oversight previews must render effect and rank summaries as
structured cards. Serialized JSON belongs only in the expandable raw-evidence structured cards. Serialized JSON belongs only in the expandable raw-evidence
panel. panel.
Exclusive child-record relationships use a searchable assignment block directly
after the record identity. The header names the parent type, states the
relationship and capacity in plain language, and shows current usage as `N / N`.
The registry repeats the assigned parent beside each child so exclusivity can be
audited without opening the editor. Legacy records may remain visibly
`UNASSIGNED`, but creation, restoration, and successful edits require a valid
active parent. Capacity rules are enforced on the server and never communicated
by color alone.
Rank cards present the complete rank description, structured effects, point Rank cards present the complete rank description, structured effects, point
cost, Credits, and material cost together. Specialized Alpha, Beta, and Gamma cost, Credits, and material cost together. Specialized Alpha, Beta, and Gamma
components use the shared blue quality treatment and may be combined in any components use the shared blue quality treatment and may be combined in any
+32 -36
View File
@@ -1,25 +1,23 @@
# Weapon and gear stat level scaling # Weapon and gear stat level scaling
This document records the empirically observed rules for adjusting weapon and Use these observed rules to adjust weapon and gear stat ranges by item level.
gear stat ranges by item level. The rules apply independently to both endpoints Apply each rule to both endpoints of a configured range.
of a configured range.
These formulas are based on in-game observations rather than published game In-game observations, rather than published game mechanics, support these
mechanics. Preserve source observations when adding new samples and revise the formulas. Preserve source observations with new samples and revise constants
constants if a larger data set disproves them. when a larger data set disproves them.
## Numeric representation and display ## Numeric representation and display
Stat ranges must retain their unrounded decimal values. The integer shown by the Store stat ranges as unrounded decimals. The game display truncates them:
game is consistent with truncation:
```text ```text
displayedValue = floor(rawValue) displayedValue = floor(rawValue)
``` ```
Do not use `ceil`, and do not repeatedly level an already displayed integer. Avoid `ceil` and repeated leveling of displayed integers. Both accumulate
Both approaches accumulate errors. A level-1 value displayed as `0` can still errors. A level-1 display of `0` can hide a positive fractional raw value that
have a positive fractional raw value that becomes visible at a higher level. appears at a higher level.
For example, a standard gear item displayed `0 ENG` at level 1 and `2 ENG` at For example, a standard gear item displayed `0 ENG` at level 1 and `2 ENG` at
level 42. With a level-42 multiplier of `3.01`, a raw base near `0.7` explains level 42. With a level-42 multiplier of `3.01`, a raw base near `0.7` explains
@@ -30,12 +28,12 @@ floor(0.7) = 0
floor(0.7 × 3.01) = 2 floor(0.7 × 3.01) = 2
``` ```
When only displayed integers are available, a prediction can be off by one. Predictions from displayed integers can miss by one because truncation loses
That is expected information loss, not evidence for rounding upward. information. That error does not indicate rounding upward.
## Shared progression model ## Shared progression model
The stable portion of both progressions is linear, not compounded: The stable portion of both progressions is linear:
```text ```text
rawStat(level) = hiddenBase × multiplier(level) rawStat(level) = hiddenBase × multiplier(level)
@@ -48,8 +46,8 @@ fixed `5%` of the hidden base:
rawStat(level + 1) - rawStat(level) = hiddenBase × 0.05 rawStat(level + 1) - rawStat(level) = hiddenBase × 0.05
``` ```
Never calculate a new level as `previousDisplayedValue × 1.05`; that describes Do not calculate a new level as `previousDisplayedValue × 1.05`. That compound
compound growth and does not match the observations. growth model conflicts with the observations.
For a range `[minimum, maximum]`, calculate each endpoint separately: For a range `[minimum, maximum]`, calculate each endpoint separately:
@@ -58,7 +56,7 @@ scaledMinimum = hiddenMinimum × multiplier(level)
scaledMaximum = hiddenMaximum × multiplier(level) scaledMaximum = hiddenMaximum × multiplier(level)
``` ```
Only truncate when producing a game-matching display value. Truncate at the game-display boundary.
## Gear ## Gear
@@ -68,9 +66,9 @@ The observed stable gear multiplier is:
gearMultiplier(level) = 0.91 + 0.05 × level gearMultiplier(level) = 0.91 + 0.05 × level
``` ```
This rule is validated for levels 10 through 50 across Standard, Specialized, Samples across Standard, Specialized, and High-end qualities validate this rule
and High-end samples. Quality changes the hidden stat ranges; the observed for levels 10 through 50. Quality changes hidden stat ranges; the observed
late-level multiplier does not change with quality. late-level multiplier remains the same.
Given hidden level-independent endpoints: Given hidden level-independent endpoints:
@@ -115,11 +113,10 @@ ENG = floor(0.7 × 3.01) = 2
### Early gear levels ### Early gear levels
Levels 1 through 9 show a quality-dependent transition and do not follow the Levels 1 through 9 show a quality-dependent transition outside the stable
stable multiplier directly. Do not extrapolate that transition from a single multiplier. Do not extrapolate it from one level-1-to-level-2 change. Until
level-1-to-level-2 change. Until the early curves are sampled more completely, more samples define the early curves, store authoritative ranges or label
store authoritative ranges for those levels or mark calculated results as calculated results as estimates.
estimates.
## Weapons ## Weapons
@@ -179,9 +176,9 @@ multiplier, then derive the combined pool bounds by multiplying each endpoint
by three. Redistribution changes the split between the three cores without by three. Redistribution changes the split between the three cores without
changing the selected item's total allocation. changing the selected item's total allocation.
The `1.14` offset is provisional and is currently validated only for the The `1.14` offset remains provisional; observations only validate it from level
level-40-to-level-50 interval. Preserve enough decimal precision to revise it 40 to 50. Retain enough decimal precision to revise it without migrating
without migrating rounded data. rounded data.
### Weapon example ### Weapon example
@@ -192,9 +189,9 @@ A weapon changed from `753 DMG` at level 40 to `873 DMG` at level 50:
floor(872.95) = 872 floor(872.95) = 872
``` ```
The displayed-source calculation misses by one because `753` is already The calculation misses by one because `753` is truncated. The ratio applied to
truncated. Applying the ratio to the unrounded source value produces the the unrounded source value produces `873`, so calculations require stored
observed `873`. This is why calculations must use stored decimal endpoints. decimal endpoints.
The observed changes over those ten levels were linear: The observed changes over those ten levels were linear:
@@ -207,11 +204,10 @@ ENG: 45 → 52 (about 0.7 per level)
### Early weapon levels ### Early weapon levels
Do not apply the provisional late-level weapon offset to level 1. An Exotic Do not apply the provisional late-level weapon offset at level 1. An Exotic
sample's displayed level-1 values and level-50 values fit a `2.91` endpoint sample's level-1 and level-50 displays fit a `2.91` endpoint ratio for most
ratio for most stats, which is incompatible with simply extending the stats, which conflicts with extending the level-40-to-level-50 offset
level-40-to-level-50 offset backward. Weapons therefore also appear to have an backward. Weapons appear to have an early transition or quality-dependent input.
early transition or another quality-dependent input.
Until more intermediate samples are available: Until more intermediate samples are available:
+43 -37
View File
@@ -1,6 +1,6 @@
# SHD.build architecture # SHD.build architecture
This project is intentionally being built in vertical slices. The project is built in vertical slices.
## Planned stack ## Planned stack
@@ -18,7 +18,8 @@ This project is intentionally being built in vertical slices.
- Editor: manage builder source data. - Editor: manage builder source data.
- Admin: manage users, roles, source data, and audit history. - Admin: manage users, roles, source data, and audit history.
Authorization must be checked on the server for every mutation. Hiding an admin link is not authorization. Server code checks authorization for each mutation. Hiding an admin link does
not grant authorization.
Weapon and gear range calculations must preserve unrounded decimal endpoints Weapon and gear range calculations must preserve unrounded decimal endpoints
and follow the empirically validated progression rules in and follow the empirically validated progression rules in
@@ -26,16 +27,15 @@ and follow the empirically validated progression rules in
linear rather than compounded and game-matching integer values are truncated linear rather than compounded and game-matching integer values are truncated
only at the presentation boundary. only at the presentation boundary.
Local credentials are stored as Argon2id PHC strings with a unique random salt Local credentials use Argon2id PHC strings with a unique random salt per
per password. Current work factors follow OWASP's 19 MiB / 2 iteration / password. Current work factors meet OWASP's 19 MiB / 2 iteration / single-lane
single-lane minimum profile and can be increased over time; successful login minimum profile. Successful login rehashes credentials with outdated parameters.
will eventually rehash credentials whose parameters are outdated.
## Database runtime ## Database runtime
The application uses Turso Database in-process through its native Node package. The application uses Turso Database in-process through its native Node package.
It opens a local SQLite-compatible database file and does not use Turso Cloud, It opens a local SQLite-compatible database file. The application does not use
libSQL HTTP, remote replicas, auth tokens, or synchronization. Turso Cloud, libSQL HTTP, remote replicas, auth tokens, or synchronization.
The database defaults to `./data/shd-build.db`. Production deployments must The database defaults to `./data/shd-build.db`. Production deployments must
provide a persistent writable filesystem and can override the location with provide a persistent writable filesystem and can override the location with
@@ -92,13 +92,12 @@ not locked by another process.
## Development database maintenance ## Development database maintenance
The development database remains valuable persistent data, but it is no longer Treat the development database as persistent data. Stop this project's
served to outside contributors and does not require a low-downtime migration development server before backup, export, restore, migration, or other work
window. Stop only this project's development server before backup, export, requiring exclusive access. Create and verify a JSON backup, perform the
restore, migration, or other work requiring exclusive access. Create and verify prepared operation, verify schema state and critical record counts, then
a JSON backup, perform the prepared operation, verify schema state and critical restart and health-check the application. Prefer additive, repeatable
record counts, then restart and health-check the application. Prefer additive, migrations and retain the verified backup if maintenance fails.
repeatable migrations and preserve the verified backup if maintenance fails.
Production and staging remain shared persistent environments and retain the Production and staging remain shared persistent environments and retain the
deployment, backup, synchronization, and maintenance safeguards documented in deployment, backup, synchronization, and maintenance safeguards documented in
@@ -120,13 +119,14 @@ mutation rather than deletion or modification of the original event:
before-snapshot. before-snapshot.
3. The UI requires a second, explicit confirmation. 3. The UI requires a second, explicit confirmation.
4. The server reloads the target and compares it with the original 4. The server reloads the target and compares it with the original
after-snapshot, ignoring bookkeeping timestamps. A mismatch means newer work after-snapshot, ignoring bookkeeping timestamps. A mismatch indicates newer
exists and blocks automatic compensation. work and blocks automatic compensation.
5. Referenced roles, permissions, and relationships are revalidated before 5. Referenced roles, permissions, and relationships are revalidated before
restoration. Restoring administrator authority additionally requires full restoration. Restoring administrator authority additionally requires full
administrative access. administrative access.
6. The server restores the previous state and appends an `audit.reverted` event 6. The server restores the previous state and appends an `audit.reverted` event
whose target is the original audit-event ID. whose target is the original audit-event ID.
7. A reverted event cannot be reverted again.
# Moderated game-data contributions # Moderated game-data contributions
@@ -156,8 +156,6 @@ assignment, so it applies consistently to existing users and every account
creation method. Registered agents can inspect live source data and submit creation method. Registered agents can inspect live source data and submit
moderated proposals, but cannot directly mutate canonical records, review the moderated proposals, but cannot directly mutate canonical records, review the
queue, or access unrelated ISAC operations without additional permissions. queue, or access unrelated ISAC operations without additional permissions.
7. A successfully reverted event cannot be reverted again.
Unsupported destructive identity operations, account deletion, incomplete Unsupported destructive identity operations, account deletion, incomplete
snapshots, stale targets, and events whose dependencies no longer exist are snapshots, stale targets, and events whose dependencies no longer exist are
marked non-reversible or rejected. Recovery for those cases requires a marked non-reversible or rejected. Recovery for those cases requires a
@@ -256,7 +254,7 @@ from Firepower, Toughness, Engineering, Health, or Armor.
The two secondary slots draw distinct attributes from one shared option pool. The two secondary slots draw distinct attributes from one shared option pool.
Each available secondary attribute stores its own minimum and maximum for all Each available secondary attribute stores its own minimum and maximum for all
four qualities. Gear tier determines capacity without per-item configuration: four qualities. Gear tier determines capacity without per-item configuration:
T0 gear has one Gear Mod slot, while T1 and T2 gear have two. T0 gear has one Gear Mod slot; T1 and T2 gear have two.
Canonical data is stored in `gear_mods`, `gear_mod_quality_values`, Canonical data is stored in `gear_mods`, `gear_mod_quality_values`,
`gear_mod_secondary_options`, and `gear_mod_secondary_quality_values`. `gear_mod_secondary_options`, and `gear_mod_secondary_quality_values`.
@@ -274,37 +272,45 @@ and secondary options reference active attributes that define Skill Chip
minimum and maximum roll values; a configured chip rolls one primary and two minimum and maximum roll values; a configured chip rolls one primary and two
distinct secondary stats from those separate pools. distinct secondary stats from those separate pools.
Every active Skill Chip definition belongs exclusively to one active
specialization ability through `skill_chip_sets.specialization_ability_id`.
An ability has capacity for exactly three active definitions. The save and
restore actions enforce that capacity, while legacy rows may remain nullable
until they are edited. 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 Both set-bonus progression systems use the number of equipped High-end chips as
the interpolation step. For `h` High-end chips, where `h` is zero through the interpolation step. For `h` High-end chips, where `h` is zero through
three, a configured minimum and maximum resolve as three, a configured minimum and maximum resolve as
`minimum + (maximum − minimum) × h / 3`. Zero High-end chips therefore produce `minimum + (maximum − minimum) × h / 3`. Zero High-end chips therefore produce
the minimum, while three produce the maximum. The editor stores only endpoints the minimum, while three produce the maximum. The editor stores endpoints and
and previews all four states, preventing duplicated tier data from diverging. previews all four states, preventing duplicated tier data from diverging.
Skill Chip mutations, owned relationships, and safe icon metadata are captured Skill Chip mutations, owned relationships, and safe icon metadata are captured
in the `game_data` audit stream. in the `game_data` audit stream.
# Game-data icon storage # Game-data icon storage
Talents, brand sets, OS Protocols, and Skill Chips reference uploaded artwork through the Talents, brand sets, OS Protocols, and Skill Chips reference uploaded artwork
polymorphic `game_data_icons` metadata table. Original uploads are never through the polymorphic `game_data_icons` metadata table. The shared server
retained. The shared server pipeline accepts transparent SVG, PNG, WebP, and pipeline accepts transparent SVG, PNG, WebP, and AVIF sources, rejects active
AVIF sources, rejects active or externally referenced SVG content, and uses or externally referenced SVG content, and uses Sharp’s native worker pool to
Sharp’s asynchronous native worker pool to rasterize and normalize every rasterize and normalize each accepted icon to a transparent 256 × 256 WebP.
accepted icon to a transparent 256 × 256 WebP.
Normalized files live beneath the ignored `data/game-icons/` directory and are Normalized files live beneath the ignored `data/game-icons/` directory and are
served by the validated `/icons/game-data/[type]/[id]` endpoint with immutable served by the validated `/icons/game-data/[type]/[id]` endpoint with immutable
cache headers and MIME sniffing disabled. ISAC save actions process files cache headers and MIME sniffing disabled. ISAC save actions process files
before mutation, apply upload or removal after record validation, and include before mutation, apply upload or removal after record validation, and include
only safe icon metadata—not image bytes—in their audit snapshots. safe icon metadata, rather than image bytes, in audit snapshots.
Icon metadata is optional. Canonical records and contributor proposals may be Icon metadata is optional. Canonical records and contributor proposals may be
created without an upload, allowing values and artwork to be contributed in created without an upload, allowing values and artwork to be contributed in
separate dependency-safe changes. Missing artwork is represented at render separate dependency-safe changes. Missing artwork is represented at render
time by the static `/missing-item-icon.svg` asset through the shared time by the static `/missing-item-icon.svg` asset through the shared
`MISSING_ITEM_ICON_URL` constant and `ItemIcon` component; the placeholder is never inserted into `MISSING_ITEM_ICON_URL` constant and `ItemIcon` component. The placeholder does
`game_data_icons`. A later edit or proposal can attach the real icon without not enter `game_data_icons`. A later edit or proposal can attach the real icon
recreating or replacing the canonical item. without recreating the canonical item.
# Oversight notification dispatch # Oversight notification dispatch
New Data Change and Avatar Oversight review items are routed only to agents who New Data Change and Avatar Oversight review items are routed only to agents who
@@ -321,11 +327,11 @@ scheduler instance:
``` ```
The dispatcher groups due rows by reviewer, queue, cadence, and delivery The dispatcher groups due rows by reviewer, queue, cadence, and delivery
window. Daily rows are assigned to the next 10:00 AM wall-clock boundary in window. Daily rows use the next 10:00 AM wall-clock boundary in
`America/New_York`, including daylight-saving transitions. Successfully `America/New_York`, including daylight-saving transitions. Dispatched rows
dispatched rows retain `delivered_at` evidence and are never selected again. retain `delivered_at` evidence and are excluded from later selection. Existing
The existing ISAC notification delivery preferences still determine optional ISAC notification delivery preferences determine optional browser and email
browser and email fan-out after the consolidated inbox message is created. fan-out after the consolidated inbox message is created.
## Automated ISAC system messages ## Automated ISAC system messages
+18
View File
@@ -37,6 +37,8 @@ const statements = schema
.filter(Boolean); .filter(Boolean);
for (const statement of statements) { for (const statement of statements) {
// This index is deferred until the additive legacy-column migration below.
if (statement.startsWith('CREATE INDEX IF NOT EXISTS skill_chip_sets_ability_idx')) continue;
await database.exec(statement); await database.exec(statement);
} }
@@ -425,6 +427,22 @@ if (buildColumns.length) {
} }
await database.exec('CREATE INDEX IF NOT EXISTS builds_visibility_updated_idx ON builds(visibility,updated_at DESC)'); await database.exec('CREATE INDEX IF NOT EXISTS builds_visibility_updated_idx ON builds(visibility,updated_at DESC)');
const skillChipColumns = await (await database.prepare("PRAGMA table_info('skill_chip_sets')")).all();
if (skillChipColumns.length && !skillChipColumns.some((column) => column.name === 'specialization_ability_id')) {
console.log('Associating Skill Chip definitions with specialization abilities.');
await database.exec('ALTER TABLE skill_chip_sets ADD COLUMN specialization_ability_id TEXT REFERENCES specialization_abilities(id)');
}
await database.exec('CREATE INDEX IF NOT EXISTS skill_chip_sets_ability_idx ON skill_chip_sets(specialization_ability_id,active)');
await database.exec(`UPDATE specialization_focuses SET builder_ready=0,updated_at='${new Date().toISOString()}'
WHERE builder_ready=1 AND (
SELECT COUNT(*) FROM specialization_focus_abilities link
JOIN specialization_abilities ability ON ability.id=link.ability_id AND ability.active=1
WHERE link.focus_id=specialization_focuses.id AND (
SELECT COUNT(*) FROM skill_chip_sets chips
WHERE chips.specialization_ability_id=ability.id AND chips.active=1
)=3
)<>3`);
console.log(`Applied ${statements.length} schema statements to ${databasePath}`); console.log(`Applied ${statements.length} schema statements to ${databasePath}`);
if (!databaseAlreadyExisted && process.env.SKIP_DEFAULT_USERS !== 'true') { if (!databaseAlreadyExisted && process.env.SKIP_DEFAULT_USERS !== 'true') {
+50
View File
@@ -1296,6 +1296,15 @@
.specialization-ability-summary strong{ .specialization-ability-summary strong{
font-size:13px font-size:13px
} }
.specialization-ability-summary .ability-chip-readiness{
color:var(--shd-danger);
font-size:var(--shd-type-telemetry);
font-weight:900;
letter-spacing:.55px
}
.specialization-ability-summary .ability-chip-readiness.ready{
color:var(--shd-success)
}
.specialization-ability-summary p{ .specialization-ability-summary p{
display:-webkit-box; display:-webkit-box;
overflow:hidden; overflow:hidden;
@@ -1306,6 +1315,47 @@
-webkit-box-orient:vertical; -webkit-box-orient:vertical;
-webkit-line-clamp:2 -webkit-line-clamp:2
} }
/* Exclusive child-record assignments stay close to identity and expose capacity. */
.skill-chip-ability-assignment{
margin-top:10px;
padding:9px;
border:1px solid var(--shd-border);
border-left:2px solid var(--shd-orange);
background:#0d1011
}
.skill-chip-ability-assignment>.assignment-picker{
margin-top:7px
}
.skill-chip-row{
min-width:1240px;
grid-template-columns:1.05fr 1.15fr .4fr 1fr 1fr .78fr .48fr .3fr
}
.skill-chip-row>div:nth-child(5){
display:grid;
gap:3px
}
.skill-chip-row>div:nth-child(5) small,
.skill-chip-ability-cell small{
color:#626a6c;
font-size:var(--shd-type-telemetry)
}
.skill-chip-ability-cell{
min-width:0;
display:grid;
gap:3px
}
.skill-chip-ability-cell strong{
overflow:hidden;
font-size:var(--shd-type-telemetry);
line-height:1.35;
text-overflow:ellipsis
}
@media(max-width:700px){
.skill-chip-ability-assignment{
padding:8px
}
}
.specialization-ability-summary button{ .specialization-ability-summary button{
align-self:start; align-self:start;
border:0; border:0;
@@ -44,6 +44,14 @@
: 'TREE NODE' : 'TREE NODE'
); );
const specializationRecord = $derived((currentSnapshot?.record ?? null) as Record<string, unknown> | null); const specializationRecord = $derived((currentSnapshot?.record ?? null) as Record<string, unknown> | null);
const skillChipRecord = $derived((currentSnapshot?.record ?? null) as Record<string, unknown> | null);
const skillChipFields = $derived([
{ label: 'NAME', current: skillChipRecord?.name, proposed: proposed.name },
{ label: 'SPECIALIZATION ABILITY', current: skillChipRecord?.specialization_ability_id, proposed: proposed.abilityId },
{ label: '2-PIECE ATTRIBUTE', current: (currentSnapshot?.twoPiece as any)?.stat_attribute_id, proposed: proposed.twoPieceStatId },
{ label: '2-PIECE RANGE', current: `${(currentSnapshot?.twoPiece as any)?.min_value ?? '—'} – ${(currentSnapshot?.twoPiece as any)?.max_value ?? '—'}`, proposed: `${proposed.twoPieceMin || '—'} – ${proposed.twoPieceMax || '—'}` },
{ label: '3-PIECE TALENT', current: (currentSnapshot?.talent as any)?.name, proposed: proposed.talentName }
]);
const specializationFields = $derived( const specializationFields = $derived(
action === 'saveProgression' action === 'saveProgression'
? [ ? [
@@ -142,6 +150,29 @@
</div> </div>
</article> </article>
</div> </div>
{:else if module === 'skillchips' && action === 'save'}
<div class="proposal-compare-grid">
<article class="proposal-editor-preview before">
<header><span>CURRENT LIVE · SKILL CHIP</span><b>{skillChipRecord ? 'BEFORE' : 'NO LIVE RECORD'}</b></header>
{#if skillChipRecord}
<div class="proposal-field-summary">
{#each skillChipFields as field}<div><span>{field.label}</span><strong>{shown(field.current)}</strong></div>{/each}
<div><span>ROLL POOLS</span><strong>{(currentSnapshot?.primaryOptions as any[])?.length ?? 0} PRIMARY · {(currentSnapshot?.secondaryOptions as any[])?.length ?? 0} SECONDARY</strong></div>
<div><span>TALENT STRUCTURE</span><strong>{(currentSnapshot?.talentTriggers as any[])?.length ?? 0} TRIGGERS · {(currentSnapshot?.talentEffects as any[])?.length ?? 0} EFFECTS</strong></div>
</div>
{:else}
<div class="proposal-empty-state"><b>NEW SKILL CHIP</b><span>No live registry record will be replaced.</span></div>
{/if}
</article>
<article class="proposal-editor-preview after">
<header><span>PROPOSED · SKILL CHIP</span><b>AFTER</b></header>
<div class="proposal-field-summary">
{#each skillChipFields as field}<div><span>{field.label}</span><strong>{shown(field.proposed)}</strong></div>{/each}
<div><span>ROLL POOLS</span><strong>{Array.isArray(proposed.primaryStatIds) ? proposed.primaryStatIds.length : Number(Boolean(proposed.primaryStatIds))} PRIMARY · {Array.isArray(proposed.secondaryStatIds) ? proposed.secondaryStatIds.length : Number(Boolean(proposed.secondaryStatIds))} SECONDARY</strong></div>
<div><span>TALENT STRUCTURE</span><strong>{Array.isArray(proposed.triggerIds) ? proposed.triggerIds.length : Number(Boolean(proposed.triggerIds))} TRIGGERS · {((jsonValue(proposed.talentEffects) ?? []) as any[]).length} EFFECTS</strong></div>
</div>
</article>
</div>
{:else if module === 'specializations' && ['saveProgression','saveSpecialization','saveFocus','saveAbility','saveFocusAbilities','saveNode'].includes(action)} {:else if module === 'specializations' && ['saveProgression','saveSpecialization','saveFocus','saveAbility','saveFocusAbilities','saveNode'].includes(action)}
<div class="proposal-compare-grid specialization-proposal-preview"> <div class="proposal-compare-grid specialization-proposal-preview">
<article class="proposal-editor-preview before"> <article class="proposal-editor-preview before">
@@ -0,0 +1,59 @@
import type { Database } from '@tursodatabase/database';
export async function refreshSpecializationFocusReady(database: Database, focusId: string) {
const focus = await (await database.prepare(
'SELECT id,active FROM specialization_focuses WHERE id=?'
)).get(focusId);
if (!focus) return { ready: false, pointTotal: 0, availablePoints: 0, 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
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
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()
]);
const pointForRank = new Map(rankProfiles.map((profile) => [Number(profile.rank), Number(profile.point_cost)]));
const pointTotal = nodes.reduce((sum, node) => {
for (let rank = 1; rank <= Number(node.max_rank); rank += 1) sum += pointForRank.get(rank) ?? rank;
return sum;
}, 0);
const rows = [...new Set(nodes.map((node) => Number(node.row_number)))];
const contiguousRows = rows.every((row, index) => row === index + 1);
const abilityCount = Number(abilityCountRow?.count ?? 0);
const chipReadyCount = Number(chipReadyRow?.count ?? 0);
const availablePoints = Number(config?.xp_points ?? 30) + Number(config?.shd_cache_points ?? 19);
const ready = Number(focus.active) === 1
&& abilityCount === 3
&& chipReadyCount === 3
&& nodes.length > 0
&& contiguousRows
&& pointTotal === availablePoints;
await (await database.prepare(
'UPDATE specialization_focuses SET builder_ready=?,updated_at=? WHERE id=?'
)).run(ready ? 1 : 0, new Date().toISOString(), focusId);
return { ready, pointTotal, availablePoints, abilityCount, chipReadyCount };
}
export async function refreshSpecializationFocuses(database: Database, specializationId: string) {
const focuses = await (await database.prepare(
'SELECT id FROM specialization_focuses WHERE specialization_id=?'
)).all(specializationId);
for (const focus of focuses) await refreshSpecializationFocusReady(database, String(focus.id));
}
export async function refreshAbilityFocuses(database: Database, abilityId: string) {
if (!abilityId) return;
const focuses = await (await database.prepare(
'SELECT focus_id FROM specialization_focus_abilities WHERE ability_id=?'
)).all(abilityId);
for (const focus of focuses) await refreshSpecializationFocusReady(database, String(focus.focus_id));
}
@@ -266,7 +266,7 @@ export const load: PageServerLoad = async ({ params, url }) => {
case 'skill-chips': case 'skill-chips':
countSql = `SELECT COUNT(*) AS total FROM skill_chip_sets item WHERE item.active=1${searchClause}`; 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, rowsSql = `SELECT item.*,bonus.min_value,bonus.max_value,attributes.name AS bonus_stat,attributes.unit AS bonus_unit,
talents.name AS talent_name, 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_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_secondary_options options WHERE options.skill_chip_set_id=item.id) AS secondary_count,
icons.updated_at AS icon_updated_at icons.updated_at AS icon_updated_at
@@ -274,6 +274,8 @@ export const load: PageServerLoad = async ({ params, url }) => {
LEFT JOIN skill_chip_two_piece_bonuses bonus ON bonus.skill_chip_set_id=item.id LEFT JOIN skill_chip_two_piece_bonuses bonus ON bonus.skill_chip_set_id=item.id
LEFT JOIN stat_attributes attributes ON attributes.id=bonus.stat_attribute_id LEFT JOIN stat_attributes attributes ON attributes.id=bonus.stat_attribute_id
LEFT JOIN skill_chip_talents talents ON talents.skill_chip_set_id=item.id LEFT JOIN skill_chip_talents talents ON talents.skill_chip_set_id=item.id
LEFT JOIN specialization_abilities abilities ON abilities.id=item.specialization_ability_id
LEFT JOIN specializations ON specializations.id=abilities.specialization_id
LEFT JOIN game_data_icons icons ON icons.entity_type='skill_chip' AND icons.entity_id=item.id LEFT JOIN game_data_icons icons ON icons.entity_type='skill_chip' AND icons.entity_id=item.id
WHERE item.active=1${searchClause} WHERE item.active=1${searchClause}
ORDER BY item.name COLLATE NOCASE LIMIT ? OFFSET ?`; ORDER BY item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
@@ -527,6 +529,7 @@ export const load: PageServerLoad = async ({ params, url }) => {
iconUrl: iconUrl('skill_chip',row.id,row.icon_updated_at), iconUrl: iconUrl('skill_chip',row.id,row.icon_updated_at),
badges: [{ label: 'SUPERIOR' }, { label: 'HIGH-END' }], badges: [{ label: 'SUPERIOR' }, { label: 'HIGH-END' }],
facts: [ facts: [
{ label: 'SPECIALIZATION ABILITY', value: row.ability_name ? `${row.ability_name} · ${row.specialization_name}` : 'UNASSIGNED' },
{ label: '2-PIECE BONUS', value: row.bonus_stat ? `${row.bonus_stat} · ${range(row.min_value,row.max_value,row.bonus_unit)}` : 'NOT CONFIGURED' }, { label: '2-PIECE BONUS', value: row.bonus_stat ? `${row.bonus_stat} · ${range(row.min_value,row.max_value,row.bonus_unit)}` : 'NOT CONFIGURED' },
{ label: 'PRIMARY POOL', value: `${row.primary_count} OPTIONS` }, { label: 'PRIMARY POOL', value: `${row.primary_count} OPTIONS` },
{ label: 'SECONDARY POOL', value: `${row.secondary_count} OPTIONS` } { label: 'SECONDARY POOL', value: `${row.secondary_count} OPTIONS` }
@@ -63,6 +63,31 @@ async function specializationProposalSnapshot(database: any, proposal: any) {
return null; return null;
} }
async function skillChipProposalSnapshot(database: any, id: string) {
const talent = await (await database.prepare(
'SELECT * FROM skill_chip_talents WHERE skill_chip_set_id=?'
)).get(id);
return {
record: await (await database.prepare('SELECT * FROM skill_chip_sets WHERE id=?')).get(id) ?? null,
twoPiece: await (await database.prepare(
'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,
talentTriggers: talent ? await (await database.prepare(
'SELECT trigger_id FROM skill_chip_talent_triggers WHERE talent_id=? ORDER BY trigger_id'
)).all(talent.id) : [],
talentEffects: talent ? await (await database.prepare(
'SELECT * FROM skill_chip_talent_effects WHERE talent_id=? ORDER BY sort_order'
)).all(talent.id) : []
};
}
export const load: PageServerLoad = async ({ cookies, url }) => { export const load: PageServerLoad = async ({ cookies, url }) => {
await requirePermission(await getSessionUser(cookies), 'data.approve'); await requirePermission(await getSessionUser(cookies), 'data.approve');
const database = await getDatabase(); const database = await getDatabase();
@@ -88,6 +113,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
...proposal, ...proposal,
current_snapshot: proposal.module === 'attributes' && proposal.entity_id current_snapshot: proposal.module === 'attributes' && proposal.entity_id
? await (await database.prepare('SELECT * FROM stat_attributes WHERE id=?')).get(String(proposal.entity_id)) ?? null ? 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 === 'specializations' : proposal.module === 'specializations'
? await specializationProposalSnapshot(database, proposal) ? await specializationProposalSnapshot(database, proposal)
: null : null
+77 -10
View File
@@ -5,6 +5,7 @@ import { getDatabase } from '$lib/server/database';
import { getPendingEntities } from '$lib/server/data-moderation'; import { getPendingEntities } from '$lib/server/data-moderation';
import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons'; import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons';
import { getPagination, getRegistrySearch } from '$lib/server/pagination'; import { getPagination, getRegistrySearch } from '$lib/server/pagination';
import { refreshAbilityFocuses } from '$lib/server/specialization-readiness';
import { fail } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
@@ -45,28 +46,34 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await requireDataView(await getSessionUser(cookies)); const user = await requireDataView(await getSessionUser(cookies));
const database = await getDatabase(); const database = await getDatabase();
const requestedFilter = url.searchParams.get('filter') ?? ''; const requestedFilter = url.searchParams.get('filter') ?? '';
const filter = ['active', 'archived', 'configured'].includes(requestedFilter) ? requestedFilter : ''; const filter = ['active', 'archived', 'configured', 'assigned', 'unassigned'].includes(requestedFilter) ? requestedFilter : '';
const { search, pattern } = getRegistrySearch(url); const { search, pattern } = getRegistrySearch(url);
const conditions = [ const conditions = [
...(filter === 'active' ? ['sets.active=1'] : filter === 'archived' ? ['sets.active=0'] ...(filter === 'active' ? ['sets.active=1'] : filter === 'archived' ? ['sets.active=0']
: filter === 'assigned' ? ['sets.specialization_ability_id IS NOT NULL']
: filter === 'unassigned' ? ['sets.specialization_ability_id IS NULL']
: 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)'] : []), : 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)'] : []),
...(search ? ["sets.name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : []) ...(search ? ["sets.name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : [])
]; ];
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const args = search ? [pattern] : []; const args = search ? [pattern] : [];
const [summary, filtered, pendingStats, pendingTriggers, pendingSets] = await Promise.all([ const [summary, filtered, pendingStats, pendingTriggers, pendingSets, pendingAbilities, pendingSpecializations] = await Promise.all([
(await database.prepare(`SELECT COUNT(*) AS total, (await database.prepare(`SELECT COUNT(*) AS total,
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active, 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 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 specialization_ability_id IS NULL THEN 1 ELSE 0 END) AS unassigned,
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_talents talent WHERE talent.skill_chip_set_id=skill_chip_sets.id) THEN 1 ELSE 0 END) AS configured
FROM skill_chip_sets`)).get(), FROM skill_chip_sets`)).get(),
(await database.prepare(`SELECT COUNT(*) AS total FROM skill_chip_sets sets ${where}`)).get(...args), (await database.prepare(`SELECT COUNT(*) AS total FROM skill_chip_sets sets ${where}`)).get(...args),
getPendingEntities(user.id, [{ module: 'attributes', action: 'save', kind: 'stat', fields: ['unit', 'chipMin', 'chipMax'] }]), getPendingEntities(user.id, [{ module: 'attributes', action: 'save', kind: 'stat', fields: ['unit', 'chipMin', 'chipMax'] }]),
getPendingEntities(user.id, [{ module: 'talents', action: 'saveTrigger', kind: 'trigger', fields: ['description'] }]), getPendingEntities(user.id, [{ module: 'talents', action: 'saveTrigger', kind: 'trigger', fields: ['description'] }]),
getPendingEntities(user.id, [{ module: 'skillchips', action: 'save', kind: 'skillchip', fields: ['twoPieceStatId', 'twoPieceMin', 'twoPieceMax', 'primaryStatIds', 'secondaryStatIds', 'talentName', 'descriptionTemplate', 'talentEffects', 'triggerIds'] }]) getPendingEntities(user.id, [{ module: 'skillchips', action: 'save', kind: 'skillchip', fields: ['abilityId', 'twoPieceStatId', 'twoPieceMin', 'twoPieceMax', 'primaryStatIds', 'secondaryStatIds', 'talentName', 'descriptionTemplate', 'talentEffects', 'triggerIds'] }]),
getPendingEntities(user.id, [{ module: 'specializations', action: 'saveAbility', kind: 'ability', fields: ['specializationId'] }]),
getPendingEntities(user.id, [{ module: 'specializations', action: 'saveSpecialization', kind: 'specialization' }])
]); ]);
const pagination = getPagination(url, Number(filtered?.total ?? 0)); const pagination = getPagination(url, Number(filtered?.total ?? 0));
const [sets, twoPiece, primaryOptions, secondaryOptions, talents, talentTriggers, talentEffects, stats, triggers, icons] = await Promise.all([ const [sets, twoPiece, primaryOptions, secondaryOptions, talents, talentTriggers, talentEffects, stats, triggers, abilities, specializations, abilityChipCounts, icons] = await Promise.all([
(await database.prepare(`SELECT sets.* FROM skill_chip_sets sets ${where} ORDER BY sets.active DESC,sets.name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset), (await database.prepare(`SELECT sets.* FROM skill_chip_sets sets ${where} ORDER BY sets.active DESC,sets.name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset),
(await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')).all(), (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_options')).all(),
@@ -76,6 +83,10 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
(await database.prepare('SELECT * FROM skill_chip_talent_effects ORDER BY sort_order')).all(), (await database.prepare('SELECT * FROM skill_chip_talent_effects ORDER BY sort_order')).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')).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')).all(),
(await database.prepare('SELECT id,name,description FROM talent_triggers WHERE active=1 ORDER BY name COLLATE NOCASE')).all(), (await database.prepare('SELECT id,name,description FROM talent_triggers WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare('SELECT id,name,specialization_id FROM specialization_abilities WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare('SELECT id,name FROM specializations WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare(`SELECT specialization_ability_id,COUNT(*) AS count FROM skill_chip_sets
WHERE active=1 AND specialization_ability_id IS NOT NULL GROUP BY specialization_ability_id`)).all(),
(await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='skill_chip'")).all() (await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='skill_chip'")).all()
]); ]);
const availableStats = [...pendingStats.map((item) => ({ const availableStats = [...pendingStats.map((item) => ({
@@ -84,8 +95,29 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
skill_chip_min: item.chipMin === '' ? null : Number(item.chipMin), skill_chip_min: item.chipMin === '' ? null : Number(item.chipMin),
skill_chip_max: item.chipMax === '' ? null : Number(item.chipMax) skill_chip_max: item.chipMax === '' ? null : Number(item.chipMax)
})), ...stats]; })), ...stats];
const pendingSetRows = filter === 'archived' ? [] : pendingSets.filter((set) => !search || String(set.name).toLocaleLowerCase().includes(search.toLocaleLowerCase())).map((set) => ({ const specializationNames = new Map([
...pendingSpecializations.map((item) => [String(item.id), String(item.name)] as const),
...specializations.map((item) => [String(item.id), String(item.name)] as const)
]);
const loadedAbilities = [
...pendingAbilities.map((ability) => ({
...ability,
name: `${ability.name} · ${specializationNames.get(String(ability.specializationId)) ?? 'PENDING SPECIALIZATION'}`,
chipCount: 0
})),
...abilities.map((ability) => ({
...ability,
name: `${ability.name} · ${specializationNames.get(String(ability.specialization_id)) ?? 'SPECIALIZATION'}`,
chipCount: Number(abilityChipCounts.find((item) => item.specialization_ability_id === ability.id)?.count ?? 0)
}))
];
const pendingSetRows = filter === 'archived' ? [] : pendingSets
.filter((set) => filter !== 'assigned' || Boolean(set.abilityId))
.filter((set) => filter !== 'unassigned' || !set.abilityId)
.filter((set) => !search || String(set.name).toLocaleLowerCase().includes(search.toLocaleLowerCase()))
.map((set) => ({
...set, ...set,
specialization_ability_id: set.abilityId,
iconUrl: '', iconUrl: '',
twoPiece: { twoPiece: {
stat_attribute_id: set.twoPieceStatId, stat_attribute_id: set.twoPieceStatId,
@@ -121,7 +153,17 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
stats: availableStats, stats: availableStats,
rollStats: availableStats.filter((stat) => stat.skill_chip_min !== null && stat.skill_chip_max !== null), rollStats: availableStats.filter((stat) => stat.skill_chip_min !== null && stat.skill_chip_max !== null),
triggers: [...pendingTriggers, ...triggers], triggers: [...pendingTriggers, ...triggers],
summary: { ...summary, total: Number(summary?.total ?? 0) + pendingSets.length, pending: pendingSets.length }, filter, pagination, search abilities: loadedAbilities,
summary: {
active: Number(summary?.active ?? 0),
archived: Number(summary?.archived ?? 0),
configured: Number(summary?.configured ?? 0),
total: Number(summary?.total ?? 0) + pendingSets.length,
assigned: Number(summary?.assigned ?? 0) + pendingSets.filter((set) => Boolean(set.abilityId)).length,
unassigned: Number(summary?.unassigned ?? 0) + pendingSets.filter((set) => !set.abilityId).length,
pending: pendingSets.length
},
filter, pagination, search
}; };
}; };
@@ -134,6 +176,7 @@ export const actions: Actions = {
catch (error) { if (error instanceof GameDataIconValidationError) return fail(400, { skillChipError: error.message }); throw error; } catch (error) { if (error instanceof GameDataIconValidationError) return fail(400, { skillChipError: error.message }); throw error; }
const id = String(form.get('id') ?? ''); const id = String(form.get('id') ?? '');
const name = String(form.get('name') ?? '').normalize('NFKC').trim(); const name = String(form.get('name') ?? '').normalize('NFKC').trim();
const abilityId = String(form.get('abilityId') ?? '');
const twoPieceStatId = String(form.get('twoPieceStatId') ?? ''); const twoPieceStatId = String(form.get('twoPieceStatId') ?? '');
const twoPieceMin = Number(form.get('twoPieceMin')); const twoPieceMin = Number(form.get('twoPieceMin'));
const twoPieceMax = Number(form.get('twoPieceMax')); const twoPieceMax = Number(form.get('twoPieceMax'));
@@ -144,6 +187,7 @@ export const actions: Actions = {
const triggerIds = [...new Set(form.getAll('triggerIds').map(String).filter(Boolean))]; const triggerIds = [...new Set(form.getAll('triggerIds').map(String).filter(Boolean))];
const effects = parseEffects(form.get('talentEffects')); const effects = parseEffects(form.get('talentEffects'));
if (name.length < 2 || name.length > 80) return fail(400, { skillChipError: 'Skill Chip names must be 2–80 characters.' }); if (name.length < 2 || name.length > 80) return fail(400, { skillChipError: 'Skill Chip names must be 2–80 characters.' });
if (!abilityId) return fail(400, { skillChipError: 'Assign this Skill Chip to a specialization ability.' });
if (!twoPieceStatId || !Number.isFinite(twoPieceMin) || !Number.isFinite(twoPieceMax) || twoPieceMax < twoPieceMin) return fail(400, { skillChipError: 'Configure a valid 2-piece stat range.' }); if (!twoPieceStatId || !Number.isFinite(twoPieceMin) || !Number.isFinite(twoPieceMax) || twoPieceMax < twoPieceMin) 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 (!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 (secondaryStatIds.length < 2) return fail(400, { skillChipError: 'Configure at least two possible secondary stats.' });
@@ -154,6 +198,14 @@ export const actions: Actions = {
const placeholders = [...descriptionTemplate.matchAll(/\{(\d+)\}/g)].map((match) => Number(match[1])); const placeholders = [...descriptionTemplate.matchAll(/\{(\d+)\}/g)].map((match) => Number(match[1]));
if (placeholders.some((index) => index < 1 || index > effects.length)) return fail(400, { skillChipError: 'Description placeholders must reference configured talent effects.' }); if (placeholders.some((index) => index < 1 || index > effects.length)) return fail(400, { skillChipError: 'Description placeholders must reference configured talent effects.' });
const database = await getDatabase(); 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: 'Choose an active specialization ability.' });
}
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 || 'new'))?.count ?? 0);
if (assignedCount >= 3) {
return fail(409, { skillChipError: 'That ability already has all three active Skill Chips assigned.' });
}
if (await (await database.prepare('SELECT id FROM skill_chip_sets WHERE name=? COLLATE NOCASE AND id<>?')).get(name, id || 'new')) return fail(409, { skillChipError: 'That Skill Chip set already exists.' }); if (await (await database.prepare('SELECT id FROM skill_chip_sets WHERE name=? COLLATE NOCASE AND id<>?')).get(name, id || 'new')) return fail(409, { skillChipError: 'That Skill Chip set already exists.' });
const existingTalent = id ? await (await database.prepare('SELECT id FROM skill_chip_talents WHERE skill_chip_set_id=?')).get(id) : null; const existingTalent = id ? await (await database.prepare('SELECT id FROM skill_chip_talents WHERE skill_chip_set_id=?')).get(id) : null;
if (await (await database.prepare('SELECT id FROM skill_chip_talents WHERE name=? COLLATE NOCASE AND id<>?')).get(talentName, existingTalent?.id ?? 'new')) return fail(409, { skillChipError: 'That Skill Chip talent name is already in use.' }); if (await (await database.prepare('SELECT id FROM skill_chip_talents WHERE name=? COLLATE NOCASE AND id<>?')).get(talentName, existingTalent?.id ?? 'new')) return fail(409, { skillChipError: 'That Skill Chip talent name is already in use.' });
@@ -169,10 +221,11 @@ export const actions: Actions = {
const talentId = String(existingTalent?.id ?? randomUUID()); const talentId = String(existingTalent?.id ?? randomUUID());
const now = new Date().toISOString(); const now = new Date().toISOString();
const before = id ? await snapshot(id) : null; const before = id ? await snapshot(id) : null;
const previousAbilityId = String(before?.record?.specialization_ability_id ?? '');
if (id) { if (id) {
if (!await auditRecord('skill_chip_sets', id)) return fail(404, { skillChipError: 'Skill Chip set not found.' }); if (!await auditRecord('skill_chip_sets', id)) return fail(404, { skillChipError: 'Skill Chip set not found.' });
await (await database.prepare('UPDATE skill_chip_sets SET name=?,active=1,updated_at=? WHERE id=?')).run(name, now, id); await (await database.prepare('UPDATE skill_chip_sets SET name=?,specialization_ability_id=?,active=1,updated_at=? WHERE id=?')).run(name, abilityId, now, id);
} else await (await database.prepare('INSERT INTO skill_chip_sets(id,name,created_at,updated_at) VALUES(?,?,?,?)')).run(setId, name, now, now); } else await (await database.prepare('INSERT INTO skill_chip_sets(id,name,specialization_ability_id,created_at,updated_at) VALUES(?,?,?,?,?)')).run(setId, name, abilityId, now, now);
await (await database.prepare(`INSERT INTO skill_chip_two_piece_bonuses(id,skill_chip_set_id,stat_attribute_id,min_value,max_value) VALUES(?,?,?,?,?) await (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); 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); await (await database.prepare('DELETE FROM skill_chip_primary_options WHERE skill_chip_set_id=?')).run(setId);
@@ -186,6 +239,8 @@ export const actions: Actions = {
await (await database.prepare('DELETE FROM skill_chip_talent_effects WHERE talent_id=?')).run(talentId); await (await database.prepare('DELETE FROM skill_chip_talent_effects WHERE talent_id=?')).run(talentId);
for (const effect of effects) 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, effect.statId, effect.minValue, effect.maxValue, effect.sortOrder); for (const effect of effects) 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, effect.statId, effect.minValue, effect.maxValue, effect.sortOrder);
await applyIconMutation(database, 'skill_chip', setId, iconMutation); await applyIconMutation(database, 'skill_chip', setId, iconMutation);
await refreshAbilityFocuses(database, abilityId);
if (previousAbilityId && previousAbilityId !== abilityId) await refreshAbilityFocuses(database, previousAbilityId);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'skill_chip.updated' : 'skill_chip.created', entityId: setId, before, after: await snapshot(setId) }); await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'skill_chip.updated' : 'skill_chip.created', entityId: setId, before, after: await snapshot(setId) });
return { skillChipSaved: true, skillChipMode: id ? 'edit' : 'add', resultToken: randomUUID() }; return { skillChipSaved: true, skillChipMode: id ? 'edit' : 'add', resultToken: randomUUID() };
}, },
@@ -194,7 +249,9 @@ export const actions: Actions = {
const id = String((await request.formData()).get('id') ?? ''); const id = String((await request.formData()).get('id') ?? '');
const before = await snapshot(id); const before = await snapshot(id);
if (!before.record) return fail(404, { skillChipError: 'Skill Chip set not found.' }); if (!before.record) return fail(404, { skillChipError: 'Skill Chip set not found.' });
await (await (await getDatabase()).prepare('UPDATE skill_chip_sets SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id); 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 recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'skill_chip.archived', entityId: id, before, after: await snapshot(id) });
return { skillChipArchived: true }; return { skillChipArchived: true };
}, },
@@ -203,7 +260,17 @@ export const actions: Actions = {
const id = String((await request.formData()).get('id') ?? ''); const id = String((await request.formData()).get('id') ?? '');
const before = await snapshot(id); const before = await snapshot(id);
if (!before.record) return fail(404, { skillChipError: 'Skill Chip set not found.' }); if (!before.record) return fail(404, { skillChipError: 'Skill Chip set not found.' });
await (await (await getDatabase()).prepare('UPDATE skill_chip_sets SET active=1,updated_at=? WHERE id=?')).run(new Date().toISOString(), id); const abilityId = String(before.record.specialization_ability_id ?? '');
if (!abilityId) return fail(400, { skillChipError: 'Assign this legacy Skill Chip to an ability before restoring it.' });
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
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.' });
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 recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'skill_chip.unarchived', entityId: id, before, after: await snapshot(id) });
return { skillChipUnarchived: true }; return { skillChipUnarchived: true };
} }
+12 -5
View File
@@ -10,7 +10,7 @@
let { data, form } = $props(); let { data, form } = $props();
type TalentEffect = { stat_attribute_id: string; min_value: number | string; max_value: number | string }; type TalentEffect = { stat_attribute_id: string; min_value: number | string; max_value: number | string };
const blankTalent = () => ({ id: '', name: '', description_template: '', triggerIds: [], effects: [{ stat_attribute_id: '', min_value: '', max_value: '' }] }); const blankTalent = () => ({ id: '', name: '', description_template: '', triggerIds: [], effects: [{ stat_attribute_id: '', min_value: '', max_value: '' }] });
const blank = () => ({ id: '', name: '', iconUrl: '', active: 1, twoPiece: { stat_attribute_id: '', min_value: '', max_value: '' }, primaryStatIds: [], secondaryStatIds: [], talent: blankTalent() }); const blank = () => ({ id: '', name: '', abilityId: '', iconUrl: '', active: 1, twoPiece: { stat_attribute_id: '', min_value: '', max_value: '' }, primaryStatIds: [], secondaryStatIds: [], talent: blankTalent() });
let editing = $state<any | null>(null); let editing = $state<any | null>(null);
let highEndCount = $state('0'); let highEndCount = $state('0');
let handledResultToken = $state(''); let handledResultToken = $state('');
@@ -22,6 +22,7 @@
{ value: '3', label: '3 HIGH-END' } { value: '3', label: '3 HIGH-END' }
]; ];
const stat = (id: string) => data.stats.find((item: any) => item.id === id); const stat = (id: string) => data.stats.find((item: any) => item.id === id);
const ability = (id: string) => data.abilities.find((item: any) => item.id === id);
const format = (value: number | string, statId: string) => { const format = (value: number | string, statId: string) => {
const numeric = Number(value); const numeric = Number(value);
if (!Number.isFinite(numeric)) return '—'; if (!Number.isFinite(numeric)) return '—';
@@ -37,6 +38,7 @@
const edit = (item: any) => { const edit = (item: any) => {
editing = { editing = {
...item, ...item,
abilityId: item.specialization_ability_id ?? '',
twoPiece: item.twoPiece ? { ...item.twoPiece } : { stat_attribute_id: '', min_value: '', max_value: '' }, twoPiece: item.twoPiece ? { ...item.twoPiece } : { stat_attribute_id: '', min_value: '', max_value: '' },
primaryStatIds: [...item.primaryStatIds], primaryStatIds: [...item.primaryStatIds],
secondaryStatIds: [...item.secondaryStatIds], secondaryStatIds: [...item.secondaryStatIds],
@@ -66,7 +68,8 @@
<KpiFilters active={data.filter} items={[ <KpiFilters active={data.filter} items={[
{value:'',label:'TOTAL SETS',count:data.summary.total,description:'Show all definitions'}, {value:'',label:'TOTAL SETS',count:data.summary.total,description:'Show all definitions'},
{value:'active',label:'ACTIVE',count:data.summary.active,description:'Available to builder'}, {value:'active',label:'ACTIVE',count:data.summary.active,description:'Available to builder'},
{value:'configured',label:'CONFIGURED',count:data.summary.configured,description:'Both bonuses defined'}, {value:'assigned',label:'ABILITY LINKED',count:data.summary.assigned,description:'Assigned to a class ability'},
{value:'unassigned',label:'UNASSIGNED',count:data.summary.unassigned,description:'Legacy records needing an ability'},
{value:'archived',label:'ARCHIVED',count:data.summary.archived,description:'Hidden definitions'} {value:'archived',label:'ARCHIVED',count:data.summary.archived,description:'Hidden definitions'}
]}/> ]}/>
@@ -77,6 +80,10 @@
<input type="hidden" name="id" value={editing.id}/> <input type="hidden" name="id" value={editing.id}/>
<input type="hidden" name="talentEffects" value={JSON.stringify(editing.talent.effects.map((effect: TalentEffect) => ({ statId: effect.stat_attribute_id, minValue: effect.min_value, maxValue: effect.max_value })))}/> <input type="hidden" name="talentEffects" value={JSON.stringify(editing.talent.effects.map((effect: TalentEffect) => ({ statId: effect.stat_attribute_id, minValue: effect.min_value, maxValue: effect.max_value })))}/>
<div class="skill-chip-identity"><IconUploadField currentUrl={editing.iconUrl} label="SKILL CHIP ICON" resetToken={iconResetToken}/><label class="field">CHIP SET NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label><div class="quality-scope"><span>SUPPORTED QUALITY</span><div><b class="quality-superior">SUPERIOR</b><b class="quality-highend">HIGH-END</b></div><small>Only these qualities participate in chip scaling.</small></div></div> <div class="skill-chip-identity"><IconUploadField currentUrl={editing.iconUrl} label="SKILL CHIP ICON" resetToken={iconResetToken}/><label class="field">CHIP SET NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label><div class="quality-scope"><span>SUPPORTED QUALITY</span><div><b class="quality-superior">SUPERIOR</b><b class="quality-highend">HIGH-END</b></div><small>Only these qualities participate in chip scaling.</small></div></div>
<section class="skill-chip-ability-assignment">
<div class="selection-head"><div><span>SPECIALIZATION ABILITY</span><small>EXCLUSIVE RELATIONSHIP · THREE ACTIVE SKILL CHIPS PER ABILITY</small></div><b>{editing.abilityId ? `${ability(editing.abilityId)?.chipCount ?? 0} / 3 ASSIGNED` : 'REQUIRED'}</b></div>
<SearchableAssignment items={data.abilities} fieldName="abilityId" mode="single" compact subjectLabel="SPECIALIZATION ABILITY" searchPlaceholder="Search abilities or specializations…" emptySelectionLabel="NO ABILITY ASSIGNED" bind:value={editing.abilityId}/>
</section>
<section class="skill-chip-bonus"> <section class="skill-chip-bonus">
<div class="selection-head"><div><span>2-PIECE BONUS</span><small>MINIMUM AT ZERO HIGH-END · LINEAR TO MAXIMUM AT THREE HIGH-END</small></div><b>{format(scaled(editing.twoPiece.min_value, editing.twoPiece.max_value), editing.twoPiece.stat_attribute_id)}</b></div> <div class="selection-head"><div><span>2-PIECE BONUS</span><small>MINIMUM AT ZERO HIGH-END · LINEAR TO MAXIMUM AT THREE HIGH-END</small></div><b>{format(scaled(editing.twoPiece.min_value, editing.twoPiece.max_value), editing.twoPiece.stat_attribute_id)}</b></div>
@@ -98,15 +105,15 @@
</div> </div>
</section> </section>
{#if form?.skillChipError}<p class="error">{form.skillChipError}</p>{/if} {#if form?.skillChipError}<p class="error">{form.skillChipError}</p>{/if}
<div class="form-actions split-actions"><div>{#if editing.id}<button class="danger" formaction={Number(editing.active) ? '?/archive' : '?/unarchive'} name="id" value={editing.id}>{Number(editing.active) ? 'ARCHIVE CHIP SET' : 'UNARCHIVE CHIP SET'}</button>{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary" disabled={!editing.primaryStatIds.length || editing.secondaryStatIds.length < 2 || !data.rollStats.length || !data.triggers.length}>SAVE SKILL CHIP →</button></div></div> <div class="form-actions split-actions"><div>{#if editing.id}<button class="danger" formaction={Number(editing.active) ? '?/archive' : '?/unarchive'} name="id" value={editing.id}>{Number(editing.active) ? 'ARCHIVE CHIP SET' : 'UNARCHIVE CHIP SET'}</button>{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary" disabled={!editing.abilityId || !editing.primaryStatIds.length || editing.secondaryStatIds.length < 2 || !data.rollStats.length || !data.triggers.length}>SAVE SKILL CHIP →</button></div></div>
</PendingForm> </PendingForm>
</div> </div>
{/if} {/if}
<RegistryPagination pagination={data.pagination} search={data.search}/> <RegistryPagination pagination={data.pagination} search={data.search}/>
<div class="dashboard-table"> <div class="dashboard-table">
<div class="dashboard-row skill-chip-row table-heading"><span>SKILL CHIP</span><span>QUALITY</span><span>2-PIECE BONUS</span><span>3-PIECE TALENT</span><span>ROLL POOLS</span><span>STATE</span><span></span></div> <div class="dashboard-row skill-chip-row table-heading"><span>SKILL CHIP</span><span>ABILITY</span><span>QUALITY</span><span>2-PIECE BONUS</span><span>3-PIECE TALENT</span><span>ROLL POOLS</span><span>STATE</span><span></span></div>
{#each data.sets as item} {#each data.sets as item}
<div class="dashboard-row skill-chip-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : 'SUPERIOR · HIGH-END'}</small></div></div><span class="skill-chip-quality-pair"><b class="quality-superior">SUP</b><b class="quality-highend">HE</b></span><span>{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'}</span><div><strong>{item.talent?.name ?? 'NOT CONFIGURED'}</strong><small>{item.talent?.effects.length ?? 0} SCALED EFFECTS</small></div><span>{item.primaryStatIds.length} PRIMARY · {item.secondaryStatIds.length} SECONDARY</span><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button onclick={() => edit(item)}>EDIT</button>{/if}</div> <div class="dashboard-row skill-chip-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : 'SUPERIOR · HIGH-END'}</small></div></div><div class="skill-chip-ability-cell"><strong>{ability(String(item.specialization_ability_id ?? ''))?.name ?? 'UNASSIGNED'}</strong><small>{ability(String(item.specialization_ability_id ?? '')) ? 'EXCLUSIVE ABILITY' : 'LEGACY RECORD'}</small></div><span class="skill-chip-quality-pair"><b class="quality-superior">SUP</b><b class="quality-highend">HE</b></span><span>{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'}</span><div><strong>{item.talent?.name ?? 'NOT CONFIGURED'}</strong><small>{item.talent?.effects.length ?? 0} SCALED EFFECTS</small></div><span>{item.primaryStatIds.length} PRIMARY · {item.secondaryStatIds.length} SECONDARY</span><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button onclick={() => edit(item)}>EDIT</button>{/if}</div>
{:else}<div class="empty-dashboard"><strong>NO SKILL CHIP SETS REGISTERED</strong><span>Add the first Superior and High-end Skill Chip definition.</span></div>{/each} {:else}<div class="empty-dashboard"><strong>NO SKILL CHIP SETS REGISTERED</strong><span>Add the first Superior and High-end Skill Chip definition.</span></div>{/each}
</div> </div>
+16 -49
View File
@@ -15,6 +15,10 @@ import {
GameDataIconValidationError, GameDataIconValidationError,
readIconMutation readIconMutation
} from '$lib/server/game-data-icons'; } from '$lib/server/game-data-icons';
import {
refreshSpecializationFocusReady,
refreshSpecializationFocuses
} from '$lib/server/specialization-readiness';
const PRIMARY_ATTRIBUTES = ['Firepower', 'Toughness', 'Engineering'] as const; const PRIMARY_ATTRIBUTES = ['Firepower', 'Toughness', 'Engineering'] as const;
const RANK_TWO_RESOURCES = new Set([ const RANK_TWO_RESOURCES = new Set([
@@ -114,48 +118,6 @@ async function nodeSnapshot(database: Database, id: string) {
}; };
} }
async function refreshFocusReady(database: Database, focusId: string) {
const focus = await (await database.prepare(
'SELECT id,active FROM specialization_focuses WHERE id=?'
)).get(focusId);
if (!focus) return { ready: false, pointTotal: 0, availablePoints: 0, abilityCount: 0 };
const [config, abilityCountRow, 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 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), Number(profile.point_cost)]));
const pointTotal = nodes.reduce((sum, node) => {
for (let rank = 1; rank <= Number(node.max_rank); rank += 1) sum += pointForRank.get(rank) ?? rank;
return sum;
}, 0);
const rows = [...new Set(nodes.map((node) => Number(node.row_number)))];
const contiguousRows = rows.every((row, index) => row === index + 1);
const abilityCount = Number(abilityCountRow?.count ?? 0);
const availablePoints = Number(config?.xp_points ?? 30) + Number(config?.shd_cache_points ?? 19);
const ready = Number(focus.active) === 1
&& abilityCount === 3
&& nodes.length > 0
&& contiguousRows
&& pointTotal === availablePoints;
await (await database.prepare(
'UPDATE specialization_focuses SET builder_ready=?,updated_at=? WHERE id=?'
)).run(ready ? 1 : 0, new Date().toISOString(), focusId);
return { ready, pointTotal, availablePoints, abilityCount };
}
async function refreshSpecializationFocuses(database: Database, specializationId: string) {
const focuses = await (await database.prepare(
'SELECT id FROM specialization_focuses WHERE specialization_id=?'
)).all(specializationId);
for (const focus of focuses) await refreshFocusReady(database, String(focus.id));
}
async function validateAttributeIds(database: Database, ids: string[]) { async function validateAttributeIds(database: Database, ids: string[]) {
const unique = [...new Set(ids.filter(Boolean))]; const unique = [...new Set(ids.filter(Boolean))];
if (!unique.length) return true; if (!unique.length) return true;
@@ -218,7 +180,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const pendingOnPage = pagination.page === 1 ? pendingSpecializations.slice(0, pagination.perPage) : []; const pendingOnPage = pagination.page === 1 ? pendingSpecializations.slice(0, pagination.perPage) : [];
const databaseOffset = Math.max(0, pagination.offset - pendingSpecializations.length); const databaseOffset = Math.max(0, pagination.offset - pendingSpecializations.length);
const databaseLimit = Math.max(0, pagination.perPage - pendingOnPage.length); const databaseLimit = Math.max(0, pagination.perPage - pendingOnPage.length);
const [specializations, focuses, abilities, abilityEffects, focusAbilities, nodes, nodeRanks, nodeEffects, nodeCosts, resources, rankProfiles, progression, attributes, icons, summary] = await Promise.all([ const [specializations, focuses, abilities, abilityEffects, focusAbilities, nodes, nodeRanks, nodeEffects, nodeCosts, resources, rankProfiles, progression, attributes, chipCounts, icons, summary] = await Promise.all([
databaseLimit ? (await database.prepare( databaseLimit ? (await database.prepare(
`SELECT * FROM specializations ${where} ORDER BY active DESC,name COLLATE NOCASE LIMIT ? OFFSET ?` `SELECT * FROM specializations ${where} ORDER BY active DESC,name COLLATE NOCASE LIMIT ? OFFSET ?`
)).all(...args, databaseLimit, databaseOffset) : [], )).all(...args, databaseLimit, databaseOffset) : [],
@@ -234,6 +196,9 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
(await database.prepare('SELECT * FROM specialization_rank_cost_profiles ORDER BY rank')).all(), (await database.prepare('SELECT * FROM specialization_rank_cost_profiles ORDER BY rank')).all(),
(await database.prepare("SELECT * FROM specialization_progression_config WHERE id='default'")).get(), (await database.prepare("SELECT * FROM specialization_progression_config WHERE id='default'")).get(),
(await database.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(), (await database.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare(`SELECT specialization_ability_id,COUNT(*) AS count
FROM skill_chip_sets WHERE active=1 AND specialization_ability_id IS NOT NULL
GROUP BY specialization_ability_id`)).all(),
(await database.prepare(`SELECT entity_type,entity_id,updated_at FROM game_data_icons (await database.prepare(`SELECT entity_type,entity_id,updated_at FROM game_data_icons
WHERE entity_type IN ('specialization','specialization_ability','specialization_node')`)).all(), WHERE entity_type IN ('specialization','specialization_ability','specialization_node')`)).all(),
(await database.prepare(`SELECT (await database.prepare(`SELECT
@@ -282,11 +247,13 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
effects: parseJsonArray<AbilityEffectInput>(item.effectsJson), effects: parseJsonArray<AbilityEffectInput>(item.effectsJson),
active: 1, active: 1,
pending: true, pending: true,
skillChipCount: 0,
iconUrl: '' iconUrl: ''
})), })),
...abilities.map((ability) => ({ ...abilities.map((ability) => ({
...ability, ...ability,
effects: abilityEffects.filter((effect) => effect.ability_id === ability.id), effects: abilityEffects.filter((effect) => effect.ability_id === ability.id),
skillChipCount: Number(chipCounts.find((item) => item.specialization_ability_id === ability.id)?.count ?? 0),
iconUrl: iconUrl('specialization_ability', String(ability.id)) iconUrl: iconUrl('specialization_ability', String(ability.id))
})) }))
]; ];
@@ -494,7 +461,7 @@ export const actions: Actions = {
VALUES(?,?,?,?,?,?,?,?)`)) VALUES(?,?,?,?,?,?,?,?)`))
.run(focusId, specializationId, name, description || null, primaryAttribute, displayOrder, now, now); .run(focusId, specializationId, name, description || null, primaryAttribute, displayOrder, now, now);
} }
await refreshFocusReady(database, focusId); await refreshSpecializationFocusReady(database, focusId);
await recordIsacAudit({ await recordIsacAudit({
actorId: operator.id, actorId: operator.id,
category: 'game_data', category: 'game_data',
@@ -647,7 +614,7 @@ export const actions: Actions = {
await database.exec('ROLLBACK'); await database.exec('ROLLBACK');
throw error; throw error;
} }
const readiness = await refreshFocusReady(database, focusId); const readiness = await refreshSpecializationFocusReady(database, focusId);
await recordIsacAudit({ await recordIsacAudit({
actorId: operator.id, actorId: operator.id,
category: 'game_data', category: 'game_data',
@@ -802,7 +769,7 @@ export const actions: Actions = {
throw error; throw error;
} }
await applyIconMutation(database, 'specialization_node', nodeId, iconMutation); await applyIconMutation(database, 'specialization_node', nodeId, iconMutation);
const readiness = await refreshFocusReady(database, focusId); const readiness = await refreshSpecializationFocusReady(database, focusId);
await recordIsacAudit({ await recordIsacAudit({
actorId: operator.id, actorId: operator.id,
category: 'game_data', category: 'game_data',
@@ -852,7 +819,7 @@ export const actions: Actions = {
const database = await getDatabase(); const database = await getDatabase();
const before = await focusSnapshot(database, id); const before = await focusSnapshot(database, id);
await (await database.prepare('UPDATE specialization_focuses SET active=1,updated_at=? WHERE id=?')).run(new Date().toISOString(), id); await (await database.prepare('UPDATE specialization_focuses SET active=1,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
const readiness = await refreshFocusReady(database, id); const readiness = await refreshSpecializationFocusReady(database, id);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'specialization_focus.unarchived', entityId: id, before, after: { ...(await focusSnapshot(database, id)), readiness } }); await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'specialization_focus.unarchived', entityId: id, before, after: { ...(await focusSnapshot(database, id)), readiness } });
return { focusUnarchived: true, resultToken: randomUUID() }; return { focusUnarchived: true, resultToken: randomUUID() };
}, },
@@ -888,7 +855,7 @@ export const actions: Actions = {
const node = await (await database.prepare('SELECT focus_id FROM specialization_tree_nodes WHERE id=?')).get(id); const node = await (await database.prepare('SELECT focus_id FROM specialization_tree_nodes WHERE id=?')).get(id);
const before = await nodeSnapshot(database, id); const before = await nodeSnapshot(database, id);
await (await database.prepare('UPDATE specialization_tree_nodes SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id); await (await database.prepare('UPDATE specialization_tree_nodes SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
const readiness = node ? await refreshFocusReady(database, String(node.focus_id)) : null; const readiness = node ? await refreshSpecializationFocusReady(database, String(node.focus_id)) : null;
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'specialization_node.archived', entityId: id, before, after: { ...(await nodeSnapshot(database, id)), readiness } }); await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'specialization_node.archived', entityId: id, before, after: { ...(await nodeSnapshot(database, id)), readiness } });
return { nodeArchived: true, resultToken: randomUUID() }; return { nodeArchived: true, resultToken: randomUUID() };
}, },
@@ -900,7 +867,7 @@ export const actions: Actions = {
const node = await (await database.prepare('SELECT focus_id FROM specialization_tree_nodes WHERE id=?')).get(id); const node = await (await database.prepare('SELECT focus_id FROM specialization_tree_nodes WHERE id=?')).get(id);
const before = await nodeSnapshot(database, id); const before = await nodeSnapshot(database, id);
await (await database.prepare('UPDATE specialization_tree_nodes SET active=1,updated_at=? WHERE id=?')).run(new Date().toISOString(), id); await (await database.prepare('UPDATE specialization_tree_nodes SET active=1,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
const readiness = node ? await refreshFocusReady(database, String(node.focus_id)) : null; const readiness = node ? await refreshSpecializationFocusReady(database, String(node.focus_id)) : null;
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'specialization_node.unarchived', entityId: id, before, after: { ...(await nodeSnapshot(database, id)), readiness } }); await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'specialization_node.unarchived', entityId: id, before, after: { ...(await nodeSnapshot(database, id)), readiness } });
return { nodeUnarchived: true, resultToken: randomUUID() }; return { nodeUnarchived: true, resultToken: randomUUID() };
} }
+10 -2
View File
@@ -101,6 +101,7 @@
const specializationById = (id: string) => data.specializations.find((item: any) => item.id === id); const specializationById = (id: string) => data.specializations.find((item: any) => item.id === id);
const focusById = (id: string) => data.focuses.find((item: any) => item.id === id); const focusById = (id: string) => data.focuses.find((item: any) => item.id === id);
const abilityById = (id: string) => data.abilities.find((item: any) => item.id === id); const abilityById = (id: string) => data.abilities.find((item: any) => item.id === id);
const abilityChipCount = (ability: any) => Number(ability?.skillChipCount ?? 0);
const focusList = (specializationId: string) => data.focuses.filter((focus: any) => focus.specialization_id === specializationId); const focusList = (specializationId: string) => data.focuses.filter((focus: any) => focus.specialization_id === specializationId);
const abilityList = (specializationId: string) => data.abilities.filter((ability: any) => const abilityList = (specializationId: string) => data.abilities.filter((ability: any) =>
ability.specialization_id === specializationId && Number(ability.active) === 1 ability.specialization_id === specializationId && Number(ability.active) === 1
@@ -543,7 +544,14 @@
{@const ability = assignedAbility(slot)} {@const ability = assignedAbility(slot)}
<article class:tree-linked={slot !== 2}> <article class:tree-linked={slot !== 2}>
<ItemIcon src={ability?.iconUrl} alt={ability?.name ?? `Ability slot ${slot}`}/> <ItemIcon src={ability?.iconUrl} alt={ability?.name ?? `Ability slot ${slot}`}/>
<div><span>ABILITY {slot} · {slot === 2 ? 'COMBAT SKILLS' : 'TREE + COMBAT SKILLS'}</span><strong>{ability?.name ?? 'NOT ASSIGNED'}</strong><p>{ability?.description ?? 'Assign an active class ability to complete this focus.'}</p></div> <div>
<span>ABILITY {slot} · {slot === 2 ? 'COMBAT SKILLS' : 'TREE + COMBAT SKILLS'}</span>
<strong>{ability?.name ?? 'NOT ASSIGNED'}</strong>
{#if ability}
<small class:ready={abilityChipCount(ability) === 3} class="ability-chip-readiness">{abilityChipCount(ability)} / 3 SKILL CHIPS · {abilityChipCount(ability) === 3 ? 'READY' : 'INCOMPLETE'}</small>
{/if}
<p>{ability?.description ?? 'Assign an active class ability to complete this focus.'}</p>
</div>
{#if ability}<button class="specialization-mutation" onclick={() => editAbility(ability)}>EDIT</button>{/if} {#if ability}<button class="specialization-mutation" onclick={() => editAbility(ability)}>EDIT</button>{/if}
</article> </article>
{/each} {/each}
@@ -558,7 +566,7 @@
{#each data.abilities.filter((ability: any) => ability.specialization_id === selectedSpecialization.id) as ability} {#each data.abilities.filter((ability: any) => ability.specialization_id === selectedSpecialization.id) as ability}
<article class:archived={Number(ability.active) === 0}> <article class:archived={Number(ability.active) === 0}>
<ItemIcon src={ability.iconUrl} alt={ability.name}/> <ItemIcon src={ability.iconUrl} alt={ability.name}/>
<div><strong>{ability.name}</strong><small>{Number(ability.active) === 1 ? 'ACTIVE CLASS ABILITY' : 'ARCHIVED CLASS ABILITY'}</small></div> <div><strong>{ability.name}</strong><small>{Number(ability.active) === 1 ? `${abilityChipCount(ability)} / 3 SKILL CHIPS` : 'ARCHIVED CLASS ABILITY'}</small></div>
<button class="secondary specialization-mutation" onclick={() => editAbility(ability)}>{Number(ability.active) === 1 ? 'EDIT' : 'REVIEW + RESTORE'} →</button> <button class="secondary specialization-mutation" onclick={() => editAbility(ability)}>{Number(ability.active) === 1 ? 'EDIT' : 'REVIEW + RESTORE'} →</button>
</article> </article>
{:else} {:else}