feat(isac): link skill chips to abilities
This commit is contained in:
@@ -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:
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() };
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user