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
## 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
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`.
Actions describe the exact event in namespaced past tense, such as
`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
other linked records, include the meaningful previous and resulting sets.
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.
Before deleting, replacing, recreating, renaming, or destructively migrating a
database—and before any major schema change—create a timestamped JSON backup
with `npm run db:backup`.
database, create a timestamped JSON backup with `npm run db:backup`. Create the
same backup before any major schema change.
Database work must follow these rules:
+40 -43
View File
@@ -114,17 +114,16 @@ components:
**Creative North Star: "The Strategic Operations Grid"**
SHD.build behaves like a dependable intelligence network shared by active
agents: precise, restrained, tactical, and structured around trustworthy data.
The interface communicates through alignment, density, operational labels, and
visible state rather than spectacle. Public surfaces can breathe and use larger
editorial hierarchy, while ISAC compresses the same identity into an efficient
control surface.
SHD.build serves active agents as a dependable intelligence network: precise,
restrained, tactical, and built around trustworthy data. Alignment, density,
operational labels, and visible state carry the interface. Public surfaces use
space and larger editorial hierarchy; ISAC uses the same identity in an
efficient control surface.
The world is technical without becoming sterile. Fine structural lines,
tonally layered black and graphite surfaces, cropped game iconography, clipped
silhouettes, and tactile state changes create its character. Theme language
supports comprehension; it never becomes military cosplay.
Fine structural lines, layered black and graphite surfaces, cropped game
iconography, clipped silhouettes, and tactile state changes give the interface
its technical character. Theme language supports comprehension and avoids
military cosplay.
**Key Characteristics:**
@@ -137,15 +136,15 @@ supports comprehension; it never becomes military cosplay.
## Colors
The palette combines Operations Black, Terminal Graphite, Field Manual White,
and rare Signal Orange with semantic system and item-quality signals.
The palette uses Operations Black, Terminal Graphite, Field Manual White, rare
Signal Orange, and semantic system and item-quality signals.
### Primary
- **Signal Orange:** Primary actions, current navigation, focus, selected
controls, important values, and SHD identity details.
- **Signal Orange Hover:** Brighter confirmation that an orange action is
interactive, never a second decorative accent.
- **Signal Orange Hover:** Confirms that an orange action accepts input; never
use it as a decorative accent.
### 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
operational metadata.
**The Orange Discipline Rule.** Signal Orange identifies action and emphasis;
it does not cover large decorative areas or compete with semantic state colors.
**The Orange Discipline Rule.** Use Signal Orange for action and emphasis. Keep
it out of large decorative areas and away from semantic state colors.
**The Redundant Meaning Rule.** Color always travels with text, position, shape,
or an icon. No status or quality can depend on hue alone.
**The Redundant Meaning Rule.** Pair color with text, position, shape, or an
icon. A status or quality cannot depend on hue alone.
## Typography
@@ -187,11 +186,10 @@ sans-serif fallbacks)
sans-serif fallbacks)
**Editorial Font:** Source Serif 4 (with Georgia and serif fallbacks)
**Character:** Barlow gives the system voice a squared, compact, engineered
rhythm while remaining highly readable in dense interfaces. Source Serif 4 is
reserved for human-readable explanations, guide-like prose, and selected
descriptive passages so operational data and editorial knowledge remain
visibly distinct.
**Character:** Barlow gives the system a squared, compact, engineered voice
that remains readable in dense interfaces. Reserve Source Serif 4 for
explanations, guide-like prose, and selected descriptive passages. Keep
operational data distinct from editorial knowledge.
### Hierarchy
@@ -208,20 +206,19 @@ visibly distinct.
- **Label** (900, 0.625rem, 0.1em tracking): Uppercase telemetry, field labels,
statuses, metadata, and compact actions.
**The Two-Voice Rule.** Sans-serif operates the system; serif explains it.
Never use monospace merely to make an interface look technical.
**The Two-Voice Rule.** Sans-serif operates the system; serif explains it. Do
not use monospace to make an interface look technical.
**The Telemetry Restraint Rule.** Uppercase tracked labels are short navigational
or state-bearing signals, not paragraph typography.
## Layout
The system is an aligned grid of operational regions rather than a collection
of floating cards. Public pages use centered containers up to 1640px with
responsive outer padding. ISAC uses a compact command header, sticky 210px
module rail, and a flexible registry workspace. Builder and account surfaces
use deliberate workbench columns that collapse into a task-appropriate mobile
sequence.
The system uses an aligned grid of operational regions. Public pages use
centered containers up to 1640px with responsive outer padding. ISAC uses a
compact command header, sticky 210px module rail, and flexible registry
workspace. Builder and account surfaces use workbench columns that collapse
into a task-appropriate mobile sequence.
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
@@ -235,8 +232,8 @@ touch-oriented stacks or controlled horizontal registries. Interactive mobile
targets are at least 44px where space permits.
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
expanding empty space.
2800px, and 1.5 at 3400px. The scale preserves readable density and avoids
empty space.
**The Connected Grid Rule.** Use borders, shared edges, and aligned tracks to
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,
fine borders, clipped corners, inset active rails, and controlled overlays.
Resting cards and panels do not float. Shadows are reserved for temporary
surfaces that must detach from the document—menus, dialogs, toasts, and routed
fault consoles—and use a clear downward offset with a soft black blur.
Resting cards and panels do not float. Use shadows for temporary surfaces that
detach from the document: menus, dialogs, toasts, and routed fault consoles.
Give each a clear downward offset and soft black blur.
### 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
polygon corners when the silhouette reinforces an operational artifact.
Circular geometry is identity-specific: agent avatars, the SHD mark, compact
online indicators, and true radial or orbital data graphics. Pills are not a
general control shape.
Circular geometry identifies agent avatars, the SHD mark, compact online
indicators, and radial or orbital data graphics. Do not use pills as a general
control shape.
**The Engineered Edge Rule.** Square is the default and rounding must have
semantic justification.
**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 feel tactile and technical: a press produces immediate physical
feedback, a request exposes pending text and disables repeat activation, and
focus is unmistakable without becoming ornamental.
Components feel tactile and technical. A press produces physical feedback; a
request exposes pending text and blocks repeat activation; focus remains clear
without ornament.
### Buttons
+47 -56
View File
@@ -8,44 +8,40 @@ web
## Users
SHD.build primarily serves active *The Division Resurgence* players who want
reliable game data, accurate loadout planning, build sharing, and community
guides.
SHD.build serves active *The Division Resurgence* players who need reliable game
data, accurate loadout planning, build sharing, and community guides.
Registered players can save and share builds, publish guides, participate in
community interactions, and manage their identity and notifications. Anonymous
players can explore public content and keep local builder drafts.
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.
## Product Purpose
SHD.build is an independent community intelligence platform for *The Division
Resurgence*. It brings together a public game database, calculated loadout
builder, shared builds, detailed guides, news, and community participation.
Resurgence*. It includes a public game database, calculated loadout builder,
shared builds, guides, news, and community participation.
Success means players can confidently understand game systems, create accurate
loadouts, learn from other agents, and help keep information current as the game
changes.
Players should understand game systems, create accurate loadouts, learn from
other agents, and help keep information current as the game changes.
## Positioning
SHD.build uses one community-maintained, structured source of game data across
its public database, builder calculations, builds, guides, and contribution
workflow. ISAC lets non-technical contributors propose connected data changes
while Oversight reviews them before they become authoritative.
SHD.build uses one community-maintained, structured game-data source for its
public database, builder calculations, builds, guides, and contribution
workflow. ISAC lets non-technical contributors propose connected data changes.
Oversight reviews proposals before they become authoritative.
This makes SHD.build more than a loadout tool or static wiki: player-facing
planning and reference experiences remain connected to the same moderated data.
Player-facing planning and reference experiences use the same moderated data.
## Operating Context
Players use the site while planning characters, comparing drops, reproducing
in-game equipment, browsing community recommendations, and responding to game
updates. The interface must work from mobile devices through high-resolution
desktop displays.
Players use the site to plan characters, compare drops, reproduce in-game
equipment, browse community recommendations, and respond to game updates. The
interface must work from mobile devices through high-resolution desktop displays.
The principal public channels are News, Database, Builds, Guides, and Builder.
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
and role management, moderation, audit history, automated messages, and system
metrics. Registered agents begin with read-only access and may explicitly
enable contributor mode after accepting the contributor guidelines. Proposed
records may depend on other pending records so contributors can build connected
data chains before approval.
metrics. Registered agents begin with read-only access and can enable
contributor mode after accepting the contributor guidelines. Contributors can
connect proposed records to pending records before approval.
## Capabilities and Constraints
- The application is built with SvelteKit and Svelte.
- 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
database files and uploaded assets are persistent user data and must survive
deployments and additive schema migrations.
@@ -73,9 +68,9 @@ data chains before approval.
requires an account.
- Saved builds support private, unlisted, and public visibility.
- User deletion is soft deletion so authored builds and guides can remain while
login identities and reusable usernames are released safely.
- User-uploaded avatars are normalized for storage and enter an Oversight queue
without blocking immediate use.
the system releases login identities and reusable usernames safely.
- The application normalizes user-uploaded avatars for storage and sends them to
an Oversight queue without blocking immediate use.
- ISAC mutations are permission-controlled, append-only audited, and use
moderation proposals when the operator lacks direct-write authority.
- Game-data editors must preserve the standardized add/edit lifecycle described
@@ -88,14 +83,12 @@ data chains before approval.
## Brand Commitments
The product name is `SHD.build`, with `SHD` always capitalized. The product is
positioned as a complete *The Division Resurgence* community rather than only a
loadout network.
The product name is `SHD.build`, with `SHD` capitalized. The product serves the
full *The Division Resurgence* community, including loadout planning.
The established voice uses restrained in-universe operational language such as
agent, ISAC, Oversight, intelligence, access, transmission, and registry when
it improves comprehension. Theme must never obscure plain meaning or turn into
military cosplay.
Use restrained in-universe operational language such as agent, ISAC, Oversight,
intelligence, access, transmission, and registry when it improves comprehension.
Do not let the theme obscure plain meaning or turn into military cosplay.
The existing SHD logo, favicon, game-type icon library, quality colors, and
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
- `docs/DESIGN_SYSTEM.md` is the maintained authority for the incumbent visual
and interaction language.
- `docs/DESIGN_SYSTEM.md` defines the current visual and interaction language.
- `docs/architecture.md` documents the application architecture and staged
feature model.
- `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
artwork.
- 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
performance benchmarks are established; future work must not fabricate them.
## Product Principles
1. **One source of operational truth.** Public reference data, builder
calculations, and contributed updates must remain connected rather than
becoming divergent copies.
2. **Make deep systems understandable.** Dense game mechanics should be
explorable and precise without requiring users to understand the underlying
data model.
3. **Contribution without technical gatekeeping.** Players should be able to
improve the database through clear forms, dependencies, review status, and
actionable Oversight feedback.
4. **Trust through visible governance.** Permissions, moderation, audit
history, privacy, and system messages should make consequential states and
1. **One source of operational truth.** Keep public reference data, builder
calculations, and contributed updates connected.
2. **Make deep systems understandable.** Present dense game mechanics with
precision without requiring users to understand the underlying data model.
3. **Contribution without technical gatekeeping.** Let players improve the
database through clear forms, dependencies, review status, and actionable
Oversight feedback.
4. **Trust through visible governance.** Use permissions, moderation, audit
history, privacy, and system messages to make consequential states and
actions explicit.
5. **Preserve the community's work.** User content, contributor-entered data,
and operational history must survive deployments, migrations, archival, and
account lifecycle changes whenever policy permits.
5. **Preserve the community's work.** Keep user content, contributor-entered
data, and operational history through deployments, migrations, archival, and
account lifecycle changes when policy permits.
## Accessibility & Inclusion
SHD.build targets WCAG 2.2 AA. Interfaces must support keyboard navigation,
clear focus-visible states, screen-reader semantics, sufficient contrast,
reduced-motion preferences, and layouts that remain functional from mobile
screens through 4K desktop displays.
reduced-motion preferences, and layouts that work from mobile screens through
4K desktop displays.
State and meaning must never depend on color, icons, hover, or motion alone.
Controls need usable touch targets, and user identity must support non-ASCII
display names and diverse avatar representation.
State and meaning cannot depend on color, icons, hover, or motion alone.
Controls need usable touch targets. User identity must support non-ASCII display
names and diverse avatar representation.
+44 -46
View File
@@ -1,13 +1,15 @@
# 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
The server uses the in-process `@tursodatabase/database` package with a local
SQLite-compatible file—there is no Turso Cloud dependency.
The server uses the in-process `@tursodatabase/database` package and a local
SQLite-compatible file. Turso Cloud is not required.
Create or update the local database:
@@ -15,10 +17,10 @@ Create or update the local database:
npm run db:migrate
```
The default file is `data/shd-build.db`. Copy `.env.example` to `.env` to
override its location.
The default file is `data/shd-build.db`. Copy `.env.example` to `.env` and
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;
it defaults to `data/avatars`. The same configuration works on Windows and
Linux.
@@ -29,9 +31,9 @@ Seed the initial local accounts once:
npm run db:seed-users
```
This creates administrator `Aewyn` and standard user `Quartermaster`. Strong
temporary passwords are generated and displayed once; only salted Argon2id
hashes are stored.
The seed creates administrator `Aewyn` and standard user `Quartermaster`.
It generates and displays strong temporary passwords once, then stores only
salted Argon2id hashes.
## Develop
@@ -41,9 +43,9 @@ npm run dev
## Encrypted environment files
Development, staging, and production settings live in the committed, encrypted
`.env.development`, `.env.staging`, and `.env.production` files. Private
decryption keys live only in the ignored `.env.keys` file; back it up in a
Keep development, staging, and production settings in the committed, encrypted
`.env.development`, `.env.staging`, and `.env.production` files. Keep private
decryption keys only in the ignored `.env.keys` file and back it up in a
password manager.
```sh
@@ -56,42 +58,38 @@ npm run env:decrypt:production
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
staged plaintext environment files with encrypted versions. Git does not expose
a pre-add hook, so use `npm run env:stage` instead of directly adding these two
files when possible.
The repository pre-commit hook runs `env:stage` and replaces staged plaintext
environment files with encrypted versions. Git has no pre-add hook. Use
`npm run env:stage` instead of directly adding these two files.
Never commit or lose `.env.keys`. Production should receive
`DOTENV_PRIVATE_KEY_PRODUCTION` through its secret manager rather than copying
the keys file onto the server.
Keep `.env.keys` out of Git and retain a backup. Production deployments receive
`DOTENV_PRIVATE_KEY_PRODUCTION` through their secret manager; do not copy the
keys file to the server.
## Containers and staging deployment
The application uses the official SvelteKit Node adapter and includes a
multi-stage `Dockerfile`. Database files, database backups, user avatars, and
uploaded game-data icons live under `/app/data`, which must be a persistent
volume. Runtime secrets are injected when the container starts and are never
baked into the image.
The application uses the official SvelteKit Node adapter and a multi-stage
`Dockerfile`. Keep database files, database backups, user avatars, and uploaded
game-data icons under `/app/data` on a persistent volume. Inject runtime secrets
when the container starts; keep them out of the image.
The staging compose topology and Gitea Actions deployment are documented in
[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md). Pushes to `main` validate the
application, build the candidate image while staging remains online, stop the
application and scheduler briefly, back up the embedded database, migrate it,
and start the new release.
[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) documents the staging Compose topology
and Gitea Actions deployment. A push to `main` validates the application,
builds the candidate image while staging stays online, stops the application
and scheduler for the database backup and migration, then starts the release.
## Passwordless authentication
Passkeys are the preferred sign-in method. `APP_ORIGIN` must exactly match the
public origin used for registration and authentication, while `PASSKEY_RP_ID`
must be that hostname or a registrable parent domain. The hosted environments
use `shd.build`, allowing credentials on both `shd.build` and
`dev.shd.build`. Direct localhost development should use `localhost`.
Use passkeys as the preferred sign-in method. Set `APP_ORIGIN` to the exact
public origin for registration and authentication. Set `PASSKEY_RP_ID` to that
hostname or a registrable parent domain. Hosted environments use `shd.build`,
which permits credentials on `shd.build` and `dev.shd.build`. Use `localhost`
for direct local development.
Magic links require `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE`, `SMTP_USER`,
`SMTP_PASSWORD`, and `SMTP_FROM`. Port 465 normally uses
`SMTP_SECURE=true`; port 587 normally uses `false` and upgrades with STARTTLS.
Email identities become usable only after the account owner follows a
15-minute, single-use verification link.
`SMTP_PASSWORD`, and `SMTP_FROM`. Use `SMTP_SECURE=true` on port 465. Use
`false` on port 587 and upgrade with STARTTLS. An account owner activates an
email identity by following a 15-minute, single-use verification link.
## Validate
@@ -100,12 +98,10 @@ npm run check
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
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project
npx sv create my-app
@@ -120,7 +116,8 @@ npx sv@0.16.5 create --template minimal --types ts --install npm .
## 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
npm run dev
@@ -131,15 +128,16 @@ npm run dev -- --open
## Building
To create a production version of your app:
Build a production version:
```sh
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
[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 (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
specialization_ability_id TEXT REFERENCES specialization_abilities(id),
active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0, 1)),
created_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 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 (
id TEXT PRIMARY KEY,
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
SHD.build is packaged as a Node 24 container using the official SvelteKit Node
adapter. The image contains application code, migration and notification
scripts, and native runtime dependencies. It does not contain an environment
file, database, backups, or user uploads.
SHD.build runs in a Node 24 container through the official SvelteKit Node
adapter. The image includes application code, migration and notification
scripts, and native runtime dependencies. Environment files, databases,
backups, and user uploads remain outside the image.
## Persistent data
@@ -15,10 +15,9 @@ mounted at `/app/data`. It contains:
- normalized user avatars under `avatars/`;
- uploaded game-data icons under `game-icons/`.
Never remove this volume during routine deployments. `docker compose down`
without `--volumes` preserves it. Before any manual backup or restore, stop
both the `app` and `scheduler` services because both access the embedded
database.
Keep this volume during routine deployments. `docker compose down` without
`--volumes` preserves it. Before a manual backup or restore, stop the `app`
and `scheduler` services because both access the embedded database.
## 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
reachable from other networks.
The deployment decrypts `.env.staging` on the trusted staging runner into a
mode-`0600` runtime file. The decryption key is held by Gitea Actions, not
committed or included in the container image.
The trusted staging runner decrypts `.env.staging` into a mode-`0600` runtime
file. Gitea Actions holds the decryption key; Git and the container image do
not.
The staging container disables default-user seeding. On a brand-new staging
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`.
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
`main` and can also be started manually. It does not need registry credentials
because the trusted staging runner builds a commit-tagged image locally.
`.gitea/workflows/deploy-staging.yml` runs for pushes to `main` and manual
dispatches. The trusted staging runner builds each commit-tagged image locally,
so the workflow needs no registry credentials.
## Deployment lifecycle
@@ -110,8 +109,7 @@ The workflow:
7. waits for `/api/health/database` to report healthy;
8. retains the logs and exits unsuccessfully if health verification fails.
The short outage is limited to backup, migration, and container startup. This
follows the database-preservation policy while minimizing data-entry downtime.
The outage covers backup, migration, and container startup.
The scheduler calls a bearer-authenticated endpoint on the application service
once per minute. Scheduled message delivery therefore uses the web process's
existing embedded-database connection instead of opening the locked database
@@ -120,11 +118,10 @@ consolidation, and email digest checks and is not published through a host port.
## Initial development-data bootstrap
The first staging release can be initialized from the current development
database, including users, roles, builds, guides, proposals, audit history,
avatars, and uploaded game-data icons. This is deliberately a one-time flow:
the staging importer refuses to run after users, builds, audits, or proposals
exist in its target database.
Initialize the first staging release from the development database, including
users, roles, builds, guides, proposals, audit history, avatars, and uploaded
game-data icons. The staging importer refuses a target database that contains
users, builds, audits, or proposals.
First stop this project's development server. The embedded database must not be
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/`.
It contains user records and password hashes, so transfer it over an encrypted
channel and never add it to Git. Copy it to the staging host, then run:
The bundle includes user records and password hashes. Transfer it over an
encrypted channel, keep it out of Git, then copy it to the staging host and run:
```sh
/opt/shd-build-stage/bootstrap-staging.sh \
@@ -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
publishes a `shd-stage-isac-data` artifact for 14 days.
Download `isac-stage.json` from that workflow run. Then stop this project's
development server and run:
Download `isac-stage.json` from that workflow run, stop this project's
development server, and run:
```sh
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`
- Persistent volume: `shd-build-production-data`
Routine pushes never import or replace production data. The production deploy
stops only its application and scheduler, creates a timestamped JSON backup,
applies repeatable migrations, restarts, and verifies database health. Never
run `docker compose down --volumes` against production.
Routine pushes leave production data unchanged. The production deploy stops its
application and scheduler, creates a timestamped JSON backup, applies repeatable
migrations, restarts, and verifies database health. Keep
`docker compose down --volumes` away from production.
Add these repository Action secrets:
@@ -234,8 +231,8 @@ reuse the staging scheduler token.
### Initial production bootstrap
The one-time production bootstrap clones current development data and uploads.
Stop this project's development server before creating the bundle:
The production bootstrap clones current development data and uploads. Stop this
project's development server before creating the bundle:
```sh
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.
The workflow exports a consistent production snapshot, restarts production,
then stops staging and imports it. The staging importer creates both full and
ISAC-only safety backups before replacement. This is intentionally manual and
never runs as part of either deployment.
then stops staging and imports it. The staging importer creates full and
ISAC-only safety backups before replacement. Deployments do not run this flow.
### Promote managed images without replacing other data
@@ -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
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
icon sources use its preview output and then continue through local trimming,
white normalization, WebP conversion, and storage.
Transparent icon uploads skip remove.bg. Opaque sources use its preview output,
then pass through local trimming, white normalization, WebP conversion, and
storage.
```sh
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
```
Each import creates a full target database backup before changing anything.
New stable IDs are inserted, identical existing assets are skipped, and a
different existing record blocks the import. After reviewing an intentional
metadata or file replacement, add `--update-existing`. Imported changes append
`image.imported` or `image.import_updated` audit events. Remove the transferred
bundle after both environments have been verified because it may contain
unpublished visual assets and creator identifiers.
Each import creates a full target database backup before changes. The importer
adds new stable IDs, skips identical assets, and blocks conflicting records.
After reviewing an intentional metadata or file replacement, add
`--update-existing`. Imported changes append `image.imported` or
`image.import_updated` audit events. Remove the transferred bundle after
verifying both environments because it may contain unpublished visual assets
and creator identifiers.
The `.env.staging` and `.env.production` npm import commands are intended for
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
Division Resurgence community for builds, guides, game data, news, and
discussion. New pages should feel like part of the same tactical intelligence
network as the admin dashboard, while public pages may use more space and larger
editorial typography.
discussion. New pages use the same tactical intelligence network as the admin
dashboard. Public pages may use more space and larger editorial typography.
## Design principles
1. **Tactical, 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.
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.
1. **Tactical.** Use operational language, compact information density, restrained status indicators, and precise alignment. Avoid camouflage, distressed textures, and decorative weapon imagery in utility interfaces.
2. **Data first.** Grouping, typography, borders, and spacing establish hierarchy. Admin tools use a dependable control surface.
3. **Orange means action.** SHD orange identifies primary actions, active navigation, focus, and important values. It should not cover large areas.
4. **State is always visible.** Loading, disabled, active, archived, configured, error, and success states need both visual and textual indicators.
5. **Square and engineered.** Panels and controls use square corners, fine borders, tight spacing, and deliberate grid alignment.
@@ -20,42 +19,41 @@ editorial typography.
### Routed error console
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,
and context-appropriate recovery controls. Common statuses receive distinct
language: authentication for 401, clearance for 403, lost location for 404,
conflict for 409, saturation for 429, and service/fault language for 5xx.
Orange identifies recoverable request errors and actions; red identifies server
faults. Never display stack traces, database messages, credentials, request
queries, or raw unexpected exceptions to users. Unexpected server faults receive
a short reference ID from the global error handler for support correlation while
the complete error remains server-side. If the root application shell itself
cannot render, `src/error.html` provides a dependency-free SHD fallback surface.
faults. Do not display stack traces, database messages, credentials, request
queries, or raw unexpected exceptions. The global error handler gives each
unexpected server fault a short support reference ID and keeps the full error
server-side. If the root application shell cannot render, `src/error.html`
provides a dependency-free SHD fallback surface.
Error controls retain square geometry, focus-visible treatment, pressed motion,
plain-language labels, and stacked mobile recovery actions.
### Contact relay and legal requests
Public support uses the SHD Contact Relay surface: a broad transmission form
paired with a compact destination guide. Two-to-six destinations use the shared
segmented-choice pattern. Signed-in identity details may be prefilled, but the
public form remains available to people who cannot sign in. Discord community
access is a distinct provider-colored card; it supplements rather than replaces
the durable contact channel.
Public support uses the SHD Contact Relay: a broad transmission form and a
compact destination guide. Two-to-six destinations use the shared
segmented-choice pattern. Signed-in identity details may be prefilled; people
who cannot sign in retain access to the public form. Discord community access
uses a distinct provider-colored card alongside the durable contact channel.
The Contact hero explicitly resets the global site-header height: semantic page
`header` elements must use content-driven height and spacing rather than inherit
the 76-pixel navigation-shell constraint. Its title, description, and Discord
The Contact hero resets the global site-header height: semantic page `header`
elements use content-driven height and spacing instead of the 76-pixel
navigation-shell constraint. Its title, description, and Discord
card align within a minimum 230-pixel desktop band before the form grid begins.
The Contact page itself remains content-height-driven so high-resolution browser
zoom does not multiply a viewport minimum into a large empty region above the
footer.
The application frame is a full-viewport vertical flex stack. The shared footer
uses automatic top margin, so it rests against the bottom edge on short routes
and follows normal document flow after longer content. Individual pages must
not add spacer heights solely to position the footer. The frame compensates for
The application frame uses a full-viewport vertical flex stack. The shared
footer uses automatic top margin. It rests against the bottom edge on short
routes and follows document flow after longer content. Do not add spacer heights
only to position the footer. The frame compensates for
`--shd-display-scale` when calculating its viewport minimum so high-resolution
browser zoom does not push a short-page footer below the visible edge.
@@ -73,8 +71,8 @@ color the complete product name orange.
### Community platform positioning
SHD.build is a full Division Resurgence community, not solely a loadout tool.
Primary product language and global navigation must represent five durable
SHD.build is a Division Resurgence community. Primary product language and
global navigation represent five durable
channels: News, Database, Builds, Guides, and Builder. Loadout-specific terms
remain appropriate inside build routes, but the global brand descriptor,
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
The application uses restrained, stepped interface scaling when the effective
CSS viewport is substantially wider than a conventional desktop. At 2200px the
complete interface scales to 115%, at 2800px to 130%, and at 3400px to 150%.
This preserves the established relationship between typography, controls,
tables, spacing, hit targets, and fixed-position surfaces instead of enlarging
text alone.
The application uses restrained stepped interface scaling on wide CSS
viewports. At 2200px, the interface scales to 115%; at 2800px, to 130%; and at
3400px, to 150%. This maintains the established relationship among typography,
controls, tables, spacing, hit targets, and fixed-position surfaces.
Use the shared `--shd-display-scale` token and the root-level `zoom` treatment;
do not add module-specific 4K font-size overrides. Responsive layouts below
@@ -233,7 +229,7 @@ Use the spacing tokens for new components. Prefer `8px`, `12px`, and `20px` gaps
## 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,
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
telemetry labels.
Only the used Barlow weights (400, 600, 700, 800, and 900) and Source Serif 4
weights (400 and 600) are loaded. Both families are self-hosted, preserve
fallback stacks, and render immediately without a third-party font request.
The two-voice rule remains semantic: Barlow operates the system; Source Serif 4
Load Barlow weights 400, 600, 700, 800, and 900, plus Source Serif 4 weights
400 and 600. Both families are self-hosted, keep fallback stacks, and render
without a third-party font request. Barlow operates the system; Source Serif 4
explains it.
- Page and module headings are uppercase, heavy, and tightly spaced.
@@ -265,7 +260,7 @@ explains it.
### Global mobile navigation
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
backdrop. The panel includes Home, Discover, Builder, Guides, Sign In or My
Profile, and ISAC when authorized; authenticated agents also receive identity
@@ -289,7 +284,7 @@ squeezed beside long module headings on mobile.
### Public pages
- 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.
- Catalogue and editorial pages use `catalogue-hero` for an angular introductory
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
Blurple, and Steam uses its white linked-circle mark on Steam navy.
- 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
provider-rendered control or current pre-approved sign-in artwork where the
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.
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
Significant visual and interaction changes must update this document and the
shared implementation in the same change. A change is significant when it
introduces or materially revises a reusable token, component pattern, layout,
responsive rule, accessibility behavior, motion behavior, or state convention.
Update this document and the shared implementation with each significant visual
or interaction change. A change qualifies when it introduces or revises a
reusable token, component pattern, layout, responsive rule, accessibility
behavior, motion behavior, or state convention.
Small alignment corrections and isolated visual bug fixes do not require a
documentation change unless they establish a rule that other interfaces should
follow.
Small alignment corrections and isolated visual bug fixes need no documentation
change unless they establish a reusable rule.
Repository agents and contributors should follow the maintenance checklist in
`AGENTS.md`.
@@ -753,7 +748,7 @@ numbers.
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
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
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 →`
or `SELECTED`; do not rely on KPI-card styling alone to communicate that the
metrics are controls. Counts describe the full search scope,
not merely the visible page. Do not use aggregate facts that cannot filter the
affected registry—replace them with actionable subsets.
not merely the visible page. Replace aggregate facts that cannot filter the
affected registry with actionable subsets.
### Registry row interaction
Desktop registry rows are passive data containers unless the entire row
actually opens a detail view. Do not apply a whole-row hover surface when only
Desktop registry rows are passive data containers unless the entire row opens
a detail view. Do not apply a whole-row hover surface when only
the trailing Edit or View action is interactive. Render that action as a
compact bordered control with hover, focus-visible, and pressed states so its
interaction boundary is unmistakable.
@@ -812,7 +807,7 @@ and normalization checks remain authoritative.
### 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
registry layout. Pending proposal counts appear as orange numeric indicators in
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
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
`VIEW BUILD & REPLY`; never substitute a generic destination label when the
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
retain an accessible label and hover title. Text-only segments may omit the
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.
### Skill Chip data console
Skill Chip editing follows the dense ISAC staged-definition pattern: identity
and icon first, the 2-piece numeric bonus second, mutually exclusive primary
and secondary roll pools next, and the 3-piece talent last. Superior blue and
High-end yellow communicate the only supported qualities; orange remains
reserved for actions and current interactive state. A four-step segmented
preview exposes the linear zero-to-three High-end interpolation without storing
duplicate values. At narrow widths the roll pools and talent columns stack,
preview segments become a two-by-two grid, and interactive targets remain at
least 44 pixels tall.
Skill Chip editing uses the dense ISAC staged-definition pattern: identity and
icon, 2-piece numeric bonus, mutually exclusive primary and secondary roll
pools, then 3-piece talent. Superior blue and High-end yellow identify the
supported qualities; reserve orange for actions and the current interactive
state. A four-step segmented preview shows the linear zero-to-three High-end
interpolation without duplicate values. At narrow widths, stack roll-pool and
talent columns, use a two-by-two preview grid, and retain 44-pixel targets.
### 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
the shared upload control. Advancing that token clears the native file input,
temporary preview URL, filename, drag/paste feedback, and validation state.
This reset occurs only after a validated add succeeds; failed saves preserve
the selected image, and successful edits close the editor normally.
Reset only after a validated add succeeds. Failed saves preserve the selected
image; successful edits close the editor.
The primary `name` field in every ISAC data editor is a header-level input:
46 pixels tall with larger, heavier text. This creates a consistent first
reading point for long registries while keeping secondary metadata compact.
The primary `name` field in each ISAC data editor is a 46-pixel header-level
input with larger, heavier text. It gives long registries a consistent first
reading point while keeping secondary metadata compact.
`SearchableAssignment` is the standard relationship picker for long attribute,
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
record total, and present contribution as a bordered secondary action.
Place one discovery bar directly below the command band. Its label presents
browsing and global search as equal paths—select a registry or search all
data—and its search field searches across all public registry names and useful
Place one discovery bar below the command band. Its label gives users two equal
paths: select a registry or search all data. Its search field searches across
all public registry names and useful
descriptive data such as Talent text, Damage Types, item slots, qualities, and
Brand bonus Attributes. Do not stack a separate registry-heading bar above or
below it. Results name both the matching record and its registry, then link into
@@ -1640,12 +1633,11 @@ workflow-centric states are not exposed as primary public labels.
### ISAC progression-tree composer
Use the Specialization workbench pattern for ordered game systems whose
meaning depends on both hierarchy and spatial placement. Keep the registry,
selected parent identity, child navigation, readiness telemetry, and visual
tree in one operational surface. Editors still open above the registry and
follow the standard ISAC lifecycle; the workbench is navigation and context,
not a replacement modal.
Use the Specialization workbench pattern for ordered game systems that depend
on hierarchy and spatial placement. Keep the registry, selected parent identity,
child navigation, readiness telemetry, and visual tree in one operational
surface. Editors open above the registry and follow the standard ISAC lifecycle.
Use the workbench for navigation and context.
The tree uses a stable 12-column track per row. Row and column are explicit
numeric fields so placement is keyboard operable, auditable, and portable;
@@ -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
colored.
Treat allocation scope as first-class telemetry. Specialization progression is
unlocked once for a specialization, but every focus receives the full point
allocation independently. Show both the point total and that scope near the
tree; do not imply that focuses draw from one shared spend pool. A focus is
builder-ready only after it has three distinct active abilities, contiguous
tree rows, at least one paid node, and exactly the configured available point
total.
Show allocation scope as first-class telemetry. A specialization unlocks once;
each focus receives its own full point allocation. Show the point total and
scope near the tree. A focus becomes builder-ready after it has three distinct
active abilities, each ability has exactly three active Skill Chip definitions,
tree rows are contiguous, at least one paid node exists, and the tree uses the
configured available point total.
Compose focus identity from the uploaded specialization artwork and the
canonical Firepower, Toughness, or Engineering icon. Keep the specialization
art primary and the attribute mark as a smaller corner badge. Ability slots 1
and 3 are mirrored as free tree nodes; slot 2 is labeled Combat Skills-only and
must not appear in the upgrade tree. Universal grenade and medical-kit actions
are not specialization abilities.
Compose focus identity from uploaded specialization artwork and the canonical
Firepower, Toughness, or Engineering icon. Keep the specialization art primary
and place the attribute mark in a smaller corner badge. Mirror ability slots 1
and 3 as free tree nodes. Label slot 2 Combat Skills-only and exclude it from
the upgrade tree. Universal grenade and medical-kit actions are outside the
specialization ability set.
Use `StructuredEffectEditor` whenever an ISAC record needs an ordered set of
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
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
cost, Credits, and material cost together. Specialized Alpha, Beta, and Gamma
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
This document records the empirically observed rules for adjusting weapon and
gear stat ranges by item level. The rules apply independently to both endpoints
of a configured range.
Use these observed rules to adjust weapon and gear stat ranges by item level.
Apply each rule to both endpoints of a configured range.
These formulas are based on in-game observations rather than published game
mechanics. Preserve source observations when adding new samples and revise the
constants if a larger data set disproves them.
In-game observations, rather than published game mechanics, support these
formulas. Preserve source observations with new samples and revise constants
when a larger data set disproves them.
## Numeric representation and display
Stat ranges must retain their unrounded decimal values. The integer shown by the
game is consistent with truncation:
Store stat ranges as unrounded decimals. The game display truncates them:
```text
displayedValue = floor(rawValue)
```
Do not use `ceil`, and do not repeatedly level an already displayed integer.
Both approaches accumulate errors. A level-1 value displayed as `0` can still
have a positive fractional raw value that becomes visible at a higher level.
Avoid `ceil` and repeated leveling of displayed integers. Both accumulate
errors. A level-1 display of `0` can hide a positive fractional raw value that
appears at a higher level.
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
@@ -30,12 +28,12 @@ floor(0.7) = 0
floor(0.7 × 3.01) = 2
```
When only displayed integers are available, a prediction can be off by one.
That is expected information loss, not evidence for rounding upward.
Predictions from displayed integers can miss by one because truncation loses
information. That error does not indicate rounding upward.
## Shared progression model
The stable portion of both progressions is linear, not compounded:
The stable portion of both progressions is linear:
```text
rawStat(level) = hiddenBase × multiplier(level)
@@ -48,8 +46,8 @@ fixed `5%` of the hidden base:
rawStat(level + 1) - rawStat(level) = hiddenBase × 0.05
```
Never calculate a new level as `previousDisplayedValue × 1.05`; that describes
compound growth and does not match the observations.
Do not calculate a new level as `previousDisplayedValue × 1.05`. That compound
growth model conflicts with the observations.
For a range `[minimum, maximum]`, calculate each endpoint separately:
@@ -58,7 +56,7 @@ scaledMinimum = hiddenMinimum × multiplier(level)
scaledMaximum = hiddenMaximum × multiplier(level)
```
Only truncate when producing a game-matching display value.
Truncate at the game-display boundary.
## Gear
@@ -68,9 +66,9 @@ The observed stable gear multiplier is:
gearMultiplier(level) = 0.91 + 0.05 × level
```
This rule is validated for levels 10 through 50 across Standard, Specialized,
and High-end samples. Quality changes the hidden stat ranges; the observed
late-level multiplier does not change with quality.
Samples across Standard, Specialized, and High-end qualities validate this rule
for levels 10 through 50. Quality changes hidden stat ranges; the observed
late-level multiplier remains the same.
Given hidden level-independent endpoints:
@@ -115,11 +113,10 @@ ENG = floor(0.7 × 3.01) = 2
### Early gear levels
Levels 1 through 9 show a quality-dependent transition and do not follow the
stable multiplier directly. Do not extrapolate that transition from a single
level-1-to-level-2 change. Until the early curves are sampled more completely,
store authoritative ranges for those levels or mark calculated results as
estimates.
Levels 1 through 9 show a quality-dependent transition outside the stable
multiplier. Do not extrapolate it from one level-1-to-level-2 change. Until
more samples define the early curves, store authoritative ranges or label
calculated results as estimates.
## 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
changing the selected item's total allocation.
The `1.14` offset is provisional and is currently validated only for the
level-40-to-level-50 interval. Preserve enough decimal precision to revise it
without migrating rounded data.
The `1.14` offset remains provisional; observations only validate it from level
40 to 50. Retain enough decimal precision to revise it without migrating
rounded data.
### 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
```
The displayed-source calculation misses by one because `753` is already
truncated. Applying the ratio to the unrounded source value produces the
observed `873`. This is why calculations must use stored decimal endpoints.
The calculation misses by one because `753` is truncated. The ratio applied to
the unrounded source value produces `873`, so calculations require stored
decimal endpoints.
The observed changes over those ten levels were linear:
@@ -207,11 +204,10 @@ ENG: 45 → 52 (about 0.7 per level)
### Early weapon levels
Do not apply the provisional late-level weapon offset to level 1. An Exotic
sample's displayed level-1 values and level-50 values fit a `2.91` endpoint
ratio for most stats, which is incompatible with simply extending the
level-40-to-level-50 offset backward. Weapons therefore also appear to have an
early transition or another quality-dependent input.
Do not apply the provisional late-level weapon offset at level 1. An Exotic
sample's level-1 and level-50 displays fit a `2.91` endpoint ratio for most
stats, which conflicts with extending the level-40-to-level-50 offset
backward. Weapons appear to have an early transition or quality-dependent input.
Until more intermediate samples are available:
+43 -37
View File
@@ -1,6 +1,6 @@
# SHD.build architecture
This project is intentionally being built in vertical slices.
The project is built in vertical slices.
## Planned stack
@@ -18,7 +18,8 @@ This project is intentionally being built in vertical slices.
- Editor: manage builder source data.
- 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
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
only at the presentation boundary.
Local credentials are stored as Argon2id PHC strings with a unique random salt
per password. Current work factors follow OWASP's 19 MiB / 2 iteration /
single-lane minimum profile and can be increased over time; successful login
will eventually rehash credentials whose parameters are outdated.
Local credentials use Argon2id PHC strings with a unique random salt per
password. Current work factors meet OWASP's 19 MiB / 2 iteration / single-lane
minimum profile. Successful login rehashes credentials with outdated parameters.
## Database runtime
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,
libSQL HTTP, remote replicas, auth tokens, or synchronization.
It opens a local SQLite-compatible database file. The application does not use
Turso Cloud, libSQL HTTP, remote replicas, auth tokens, or synchronization.
The database defaults to `./data/shd-build.db`. Production deployments must
provide a persistent writable filesystem and can override the location with
@@ -92,13 +92,12 @@ not locked by another process.
## Development database maintenance
The development database remains valuable persistent data, but it is no longer
served to outside contributors and does not require a low-downtime migration
window. Stop only this project's development server before backup, export,
restore, migration, or other work requiring exclusive access. Create and verify
a JSON backup, perform the prepared operation, verify schema state and critical
record counts, then restart and health-check the application. Prefer additive,
repeatable migrations and preserve the verified backup if maintenance fails.
Treat the development database as persistent data. Stop this project's
development server before backup, export, restore, migration, or other work
requiring exclusive access. Create and verify a JSON backup, perform the
prepared operation, verify schema state and critical record counts, then
restart and health-check the application. Prefer additive, repeatable
migrations and retain the verified backup if maintenance fails.
Production and staging remain shared persistent environments and retain the
deployment, backup, synchronization, and maintenance safeguards documented in
@@ -120,13 +119,14 @@ mutation rather than deletion or modification of the original event:
before-snapshot.
3. The UI requires a second, explicit confirmation.
4. The server reloads the target and compares it with the original
after-snapshot, ignoring bookkeeping timestamps. A mismatch means newer work
exists and blocks automatic compensation.
after-snapshot, ignoring bookkeeping timestamps. A mismatch indicates newer
work and blocks automatic compensation.
5. Referenced roles, permissions, and relationships are revalidated before
restoration. Restoring administrator authority additionally requires full
administrative access.
6. The server restores the previous state and appends an `audit.reverted` event
whose target is the original audit-event ID.
7. A reverted event cannot be reverted again.
# 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
moderated proposals, but cannot directly mutate canonical records, review the
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
snapshots, stale targets, and events whose dependencies no longer exist are
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.
Each available secondary attribute stores its own minimum and maximum for all
four qualities. Gear tier determines capacity without per-item configuration:
T0 gear has one Gear Mod slot, 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`,
`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
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
the interpolation step. For `h` High-end chips, where `h` is zero through
three, a configured minimum and maximum resolve as
`minimum + (maximum − minimum) × h / 3`. Zero High-end chips therefore produce
the minimum, while three produce the maximum. The editor stores only endpoints
and previews all four states, preventing duplicated tier data from diverging.
the minimum, while three produce the maximum. The editor stores endpoints and
previews all four states, preventing duplicated tier data from diverging.
Skill Chip mutations, owned relationships, and safe icon metadata are captured
in the `game_data` audit stream.
# Game-data icon storage
Talents, brand sets, OS Protocols, and Skill Chips reference uploaded artwork through the
polymorphic `game_data_icons` metadata table. Original uploads are never
retained. The shared server pipeline accepts transparent SVG, PNG, WebP, and
AVIF sources, rejects active or externally referenced SVG content, and uses
Sharp’s asynchronous native worker pool to rasterize and normalize every
accepted icon to a transparent 256 × 256 WebP.
Talents, brand sets, OS Protocols, and Skill Chips reference uploaded artwork
through the polymorphic `game_data_icons` metadata table. The shared server
pipeline accepts transparent SVG, PNG, WebP, and AVIF sources, rejects active
or externally referenced SVG content, and uses Sharp’s native worker pool to
rasterize and normalize each accepted icon to a transparent 256 × 256 WebP.
Normalized files live beneath the ignored `data/game-icons/` directory and are
served by the validated `/icons/game-data/[type]/[id]` endpoint with immutable
cache headers and MIME sniffing disabled. ISAC save actions process files
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
created without an upload, allowing values and artwork to be contributed in
separate dependency-safe changes. Missing artwork is represented at render
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
`game_data_icons`. A later edit or proposal can attach the real icon without
recreating or replacing the canonical item.
`MISSING_ITEM_ICON_URL` constant and `ItemIcon` component. The placeholder does
not enter `game_data_icons`. A later edit or proposal can attach the real icon
without recreating the canonical item.
# Oversight notification dispatch
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
window. Daily rows are assigned to the next 10:00 AM wall-clock boundary in
`America/New_York`, including daylight-saving transitions. Successfully
dispatched rows retain `delivered_at` evidence and are never selected again.
The existing ISAC notification delivery preferences still determine optional
browser and email fan-out after the consolidated inbox message is created.
window. Daily rows use the next 10:00 AM wall-clock boundary in
`America/New_York`, including daylight-saving transitions. Dispatched rows
retain `delivered_at` evidence and are excluded from later selection. Existing
ISAC notification delivery preferences determine optional browser and email
fan-out after the consolidated inbox message is created.
## Automated ISAC system messages
+18
View File
@@ -37,6 +37,8 @@ const statements = schema
.filter(Boolean);
for (const statement of statements) {
// This index is deferred until the additive legacy-column migration below.
if (statement.startsWith('CREATE INDEX IF NOT EXISTS skill_chip_sets_ability_idx')) continue;
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)');
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}`);
if (!databaseAlreadyExisted && process.env.SKIP_DEFAULT_USERS !== 'true') {
+50
View File
@@ -1296,6 +1296,15 @@
.specialization-ability-summary strong{
font-size:13px
}
.specialization-ability-summary .ability-chip-readiness{
color:var(--shd-danger);
font-size:var(--shd-type-telemetry);
font-weight:900;
letter-spacing:.55px
}
.specialization-ability-summary .ability-chip-readiness.ready{
color:var(--shd-success)
}
.specialization-ability-summary p{
display:-webkit-box;
overflow:hidden;
@@ -1306,6 +1315,47 @@
-webkit-box-orient:vertical;
-webkit-line-clamp:2
}
/* Exclusive child-record assignments stay close to identity and expose capacity. */
.skill-chip-ability-assignment{
margin-top:10px;
padding:9px;
border:1px solid var(--shd-border);
border-left:2px solid var(--shd-orange);
background:#0d1011
}
.skill-chip-ability-assignment>.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{
align-self:start;
border:0;
@@ -44,6 +44,14 @@
: 'TREE NODE'
);
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(
action === 'saveProgression'
? [
@@ -142,6 +150,29 @@
</div>
</article>
</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)}
<div class="proposal-compare-grid specialization-proposal-preview">
<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':
countSql = `SELECT COUNT(*) AS total FROM skill_chip_sets item WHERE item.active=1${searchClause}`;
rowsSql = `SELECT item.*,bonus.min_value,bonus.max_value,attributes.name AS bonus_stat,attributes.unit AS bonus_unit,
talents.name AS talent_name,
talents.name AS talent_name,abilities.name AS ability_name,specializations.name AS specialization_name,
(SELECT COUNT(*) FROM skill_chip_primary_options options WHERE options.skill_chip_set_id=item.id) AS primary_count,
(SELECT COUNT(*) FROM skill_chip_secondary_options options WHERE options.skill_chip_set_id=item.id) AS secondary_count,
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 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 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
WHERE item.active=1${searchClause}
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),
badges: [{ label: 'SUPERIOR' }, { label: 'HIGH-END' }],
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: 'PRIMARY POOL', value: `${row.primary_count} OPTIONS` },
{ label: 'SECONDARY POOL', value: `${row.secondary_count} OPTIONS` }
@@ -63,6 +63,31 @@ async function specializationProposalSnapshot(database: any, proposal: any) {
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 }) => {
await requirePermission(await getSessionUser(cookies), 'data.approve');
const database = await getDatabase();
@@ -88,6 +113,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
...proposal,
current_snapshot: proposal.module === 'attributes' && proposal.entity_id
? 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'
? await specializationProposalSnapshot(database, proposal)
: null
+77 -10
View File
@@ -5,6 +5,7 @@ import { getDatabase } from '$lib/server/database';
import { getPendingEntities } from '$lib/server/data-moderation';
import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons';
import { getPagination, getRegistrySearch } from '$lib/server/pagination';
import { refreshAbilityFocuses } from '$lib/server/specialization-readiness';
import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
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 database = await getDatabase();
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 conditions = [
...(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)'] : []),
...(search ? ["sets.name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : [])
];
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
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,
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active,
SUM(CASE WHEN active=0 THEN 1 ELSE 0 END) AS archived,
SUM(CASE WHEN specialization_ability_id IS NOT NULL THEN 1 ELSE 0 END) AS assigned,
SUM(CASE WHEN 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
FROM skill_chip_sets`)).get(),
(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: '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 [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 * FROM skill_chip_two_piece_bonuses')).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 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,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()
]);
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_max: item.chipMax === '' ? null : Number(item.chipMax)
})), ...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,
specialization_ability_id: set.abilityId,
iconUrl: '',
twoPiece: {
stat_attribute_id: set.twoPieceStatId,
@@ -121,7 +153,17 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
stats: availableStats,
rollStats: availableStats.filter((stat) => stat.skill_chip_min !== null && stat.skill_chip_max !== null),
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; }
const id = String(form.get('id') ?? '');
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
const abilityId = String(form.get('abilityId') ?? '');
const twoPieceStatId = String(form.get('twoPieceStatId') ?? '');
const twoPieceMin = Number(form.get('twoPieceMin'));
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 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 (!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 (!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.' });
@@ -154,6 +198,14 @@ export const actions: Actions = {
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.' });
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.' });
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.' });
@@ -169,10 +221,11 @@ export const actions: Actions = {
const talentId = String(existingTalent?.id ?? randomUUID());
const now = new Date().toISOString();
const before = id ? await snapshot(id) : null;
const previousAbilityId = String(before?.record?.specialization_ability_id ?? '');
if (id) {
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);
} else await (await database.prepare('INSERT INTO skill_chip_sets(id,name,created_at,updated_at) VALUES(?,?,?,?)')).run(setId, name, now, now);
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,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(?,?,?,?,?)
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);
@@ -186,6 +239,8 @@ export const actions: Actions = {
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);
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) });
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 before = await snapshot(id);
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) });
return { skillChipArchived: true };
},
@@ -203,7 +260,17 @@ export const actions: Actions = {
const id = String((await request.formData()).get('id') ?? '');
const before = await snapshot(id);
if (!before.record) return fail(404, { skillChipError: 'Skill Chip set not found.' });
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) });
return { skillChipUnarchived: true };
}
+12 -5
View File
@@ -10,7 +10,7 @@
let { data, form } = $props();
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 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 highEndCount = $state('0');
let handledResultToken = $state('');
@@ -22,6 +22,7 @@
{ value: '3', label: '3 HIGH-END' }
];
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 numeric = Number(value);
if (!Number.isFinite(numeric)) return '—';
@@ -37,6 +38,7 @@
const edit = (item: any) => {
editing = {
...item,
abilityId: item.specialization_ability_id ?? '',
twoPiece: item.twoPiece ? { ...item.twoPiece } : { stat_attribute_id: '', min_value: '', max_value: '' },
primaryStatIds: [...item.primaryStatIds],
secondaryStatIds: [...item.secondaryStatIds],
@@ -66,7 +68,8 @@
<KpiFilters active={data.filter} items={[
{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:'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'}
]}/>
@@ -77,6 +80,10 @@
<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 })))}/>
<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">
<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>
</section>
{#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>
</div>
{/if}
<RegistryPagination pagination={data.pagination} search={data.search}/>
<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}
<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}
</div>
+16 -49
View File
@@ -15,6 +15,10 @@ import {
GameDataIconValidationError,
readIconMutation
} from '$lib/server/game-data-icons';
import {
refreshSpecializationFocusReady,
refreshSpecializationFocuses
} from '$lib/server/specialization-readiness';
const PRIMARY_ATTRIBUTES = ['Firepower', 'Toughness', 'Engineering'] as const;
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[]) {
const unique = [...new Set(ids.filter(Boolean))];
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 databaseOffset = Math.max(0, pagination.offset - pendingSpecializations.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(
`SELECT * FROM specializations ${where} ORDER BY active DESC,name COLLATE NOCASE LIMIT ? OFFSET ?`
)).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_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 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
WHERE entity_type IN ('specialization','specialization_ability','specialization_node')`)).all(),
(await database.prepare(`SELECT
@@ -282,11 +247,13 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
effects: parseJsonArray<AbilityEffectInput>(item.effectsJson),
active: 1,
pending: true,
skillChipCount: 0,
iconUrl: ''
})),
...abilities.map((ability) => ({
...ability,
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))
}))
];
@@ -494,7 +461,7 @@ export const actions: Actions = {
VALUES(?,?,?,?,?,?,?,?)`))
.run(focusId, specializationId, name, description || null, primaryAttribute, displayOrder, now, now);
}
await refreshFocusReady(database, focusId);
await refreshSpecializationFocusReady(database, focusId);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
@@ -647,7 +614,7 @@ export const actions: Actions = {
await database.exec('ROLLBACK');
throw error;
}
const readiness = await refreshFocusReady(database, focusId);
const readiness = await refreshSpecializationFocusReady(database, focusId);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
@@ -802,7 +769,7 @@ export const actions: Actions = {
throw error;
}
await applyIconMutation(database, 'specialization_node', nodeId, iconMutation);
const readiness = await refreshFocusReady(database, focusId);
const readiness = await refreshSpecializationFocusReady(database, focusId);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
@@ -852,7 +819,7 @@ export const actions: Actions = {
const database = await getDatabase();
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);
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 } });
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 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);
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 } });
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 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);
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 } });
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 focusById = (id: string) => data.focuses.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 abilityList = (specializationId: string) => data.abilities.filter((ability: any) =>
ability.specialization_id === specializationId && Number(ability.active) === 1
@@ -543,7 +544,14 @@
{@const ability = assignedAbility(slot)}
<article class:tree-linked={slot !== 2}>
<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}
</article>
{/each}
@@ -558,7 +566,7 @@
{#each data.abilities.filter((ability: any) => ability.specialization_id === selectedSpecialization.id) as ability}
<article class:archived={Number(ability.active) === 0}>
<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>
</article>
{:else}