From b1656c430fbfbb40ec9adcf7189dd3933d2db9c8 Mon Sep 17 00:00:00 2001 From: JB Date: Sun, 26 Jul 2026 17:04:47 -0400 Subject: [PATCH] feat(isac): link skill chips to abilities --- AGENTS.md | 40 +++- DESIGN.md | 83 ++++---- PRODUCT.md | 103 +++++----- README.md | 90 +++++---- database/schema.sql | 2 + docs/DEPLOYMENT.md | 84 ++++---- docs/DESIGN_SYSTEM.md | 184 +++++++++--------- docs/STAT_LEVEL_SCALING.md | 68 +++---- docs/architecture.md | 80 ++++---- scripts/db-migrate.mjs | 18 ++ src/app.css | 50 +++++ .../components/ProposalChangePreview.svelte | 31 +++ src/lib/server/specialization-readiness.ts | 59 ++++++ .../database/[category]/+page.server.ts | 5 +- .../isac/data-moderation/+page.server.ts | 27 +++ src/routes/isac/skillchips/+page.server.ts | 87 ++++++++- src/routes/isac/skillchips/+page.svelte | 17 +- .../isac/specializations/+page.server.ts | 65 ++----- src/routes/isac/specializations/+page.svelte | 12 +- 19 files changed, 681 insertions(+), 424 deletions(-) create mode 100644 src/lib/server/specialization-readiness.ts diff --git a/AGENTS.md b/AGENTS.md index d9de90c..11aba1c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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: diff --git a/DESIGN.md b/DESIGN.md index 1821854..88a08fc 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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 diff --git a/PRODUCT.md b/PRODUCT.md index 78a3379..3d6ee61 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -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. diff --git a/README.md b/README.md index bd057bc..6909a90 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/database/schema.sql b/database/schema.sql index 07461b3..afd4066 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -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, diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 9714576..6c889e4 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -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 diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index 89538c7..2072103 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -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 diff --git a/docs/STAT_LEVEL_SCALING.md b/docs/STAT_LEVEL_SCALING.md index 9cb01f6..a5b42ad 100644 --- a/docs/STAT_LEVEL_SCALING.md +++ b/docs/STAT_LEVEL_SCALING.md @@ -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: diff --git a/docs/architecture.md b/docs/architecture.md index a173f24..ce53166 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/scripts/db-migrate.mjs b/scripts/db-migrate.mjs index 0ae9fd2..809d926 100644 --- a/scripts/db-migrate.mjs +++ b/scripts/db-migrate.mjs @@ -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') { diff --git a/src/app.css b/src/app.css index fc88a78..86d7aec 100644 --- a/src/app.css +++ b/src/app.css @@ -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; diff --git a/src/lib/components/ProposalChangePreview.svelte b/src/lib/components/ProposalChangePreview.svelte index d9df659..6041274 100644 --- a/src/lib/components/ProposalChangePreview.svelte +++ b/src/lib/components/ProposalChangePreview.svelte @@ -44,6 +44,14 @@ : 'TREE NODE' ); const specializationRecord = $derived((currentSnapshot?.record ?? null) as Record | null); + const skillChipRecord = $derived((currentSnapshot?.record ?? null) as Record | 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 @@ + {:else if module === 'skillchips' && action === 'save'} +
+
+
CURRENT LIVE · SKILL CHIP{skillChipRecord ? 'BEFORE' : 'NO LIVE RECORD'}
+ {#if skillChipRecord} +
+ {#each skillChipFields as field}
{field.label}{shown(field.current)}
{/each} +
ROLL POOLS{(currentSnapshot?.primaryOptions as any[])?.length ?? 0} PRIMARY · {(currentSnapshot?.secondaryOptions as any[])?.length ?? 0} SECONDARY
+
TALENT STRUCTURE{(currentSnapshot?.talentTriggers as any[])?.length ?? 0} TRIGGERS · {(currentSnapshot?.talentEffects as any[])?.length ?? 0} EFFECTS
+
+ {:else} +
NEW SKILL CHIPNo live registry record will be replaced.
+ {/if} +
+
+
PROPOSED · SKILL CHIPAFTER
+
+ {#each skillChipFields as field}
{field.label}{shown(field.proposed)}
{/each} +
ROLL POOLS{Array.isArray(proposed.primaryStatIds) ? proposed.primaryStatIds.length : Number(Boolean(proposed.primaryStatIds))} PRIMARY · {Array.isArray(proposed.secondaryStatIds) ? proposed.secondaryStatIds.length : Number(Boolean(proposed.secondaryStatIds))} SECONDARY
+
TALENT STRUCTURE{Array.isArray(proposed.triggerIds) ? proposed.triggerIds.length : Number(Boolean(proposed.triggerIds))} TRIGGERS · {((jsonValue(proposed.talentEffects) ?? []) as any[]).length} EFFECTS
+
+
+
{:else if module === 'specializations' && ['saveProgression','saveSpecialization','saveFocus','saveAbility','saveFocusAbilities','saveNode'].includes(action)}
diff --git a/src/lib/server/specialization-readiness.ts b/src/lib/server/specialization-readiness.ts new file mode 100644 index 0000000..7227c40 --- /dev/null +++ b/src/lib/server/specialization-readiness.ts @@ -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)); +} diff --git a/src/routes/database/[category]/+page.server.ts b/src/routes/database/[category]/+page.server.ts index 45af0fa..d66cafa 100644 --- a/src/routes/database/[category]/+page.server.ts +++ b/src/routes/database/[category]/+page.server.ts @@ -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` } diff --git a/src/routes/isac/data-moderation/+page.server.ts b/src/routes/isac/data-moderation/+page.server.ts index 94b6468..3817908 100644 --- a/src/routes/isac/data-moderation/+page.server.ts +++ b/src/routes/isac/data-moderation/+page.server.ts @@ -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 diff --git a/src/routes/isac/skillchips/+page.server.ts b/src/routes/isac/skillchips/+page.server.ts index c6dae0b..dd019a9 100644 --- a/src/routes/isac/skillchips/+page.server.ts +++ b/src/routes/isac/skillchips/+page.server.ts @@ -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 }; } diff --git a/src/routes/isac/skillchips/+page.svelte b/src/routes/isac/skillchips/+page.svelte index 6510663..3e26035 100644 --- a/src/routes/isac/skillchips/+page.svelte +++ b/src/routes/isac/skillchips/+page.svelte @@ -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(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 @@ @@ -77,6 +80,10 @@ ({ statId: effect.stat_attribute_id, minValue: effect.min_value, maxValue: effect.max_value })))}/>
SUPPORTED QUALITY
SUPERIORHIGH-END
Only these qualities participate in chip scaling.
+
+
SPECIALIZATION ABILITYEXCLUSIVE RELATIONSHIP · THREE ACTIVE SKILL CHIPS PER ABILITY
{editing.abilityId ? `${ability(editing.abilityId)?.chipCount ?? 0} / 3 ASSIGNED` : 'REQUIRED'}
+ +
2-PIECE BONUSMINIMUM AT ZERO HIGH-END · LINEAR TO MAXIMUM AT THREE HIGH-END
{format(scaled(editing.twoPiece.min_value, editing.twoPiece.max_value), editing.twoPiece.stat_attribute_id)}
@@ -98,15 +105,15 @@
{#if form?.skillChipError}

{form.skillChipError}

{/if} -
{#if editing.id}{/if}
+
{#if editing.id}{/if}
{/if}
-
SKILL CHIPQUALITY2-PIECE BONUS3-PIECE TALENTROLL POOLSSTATE
+
SKILL CHIPABILITYQUALITY2-PIECE BONUS3-PIECE TALENTROLL POOLSSTATE
{#each data.sets as item} -
{item.name}{item.pending ? `PROPOSED BY ${item.proposerName}` : 'SUPERIOR · HIGH-END'}
SUPHE{item.twoPiece ? `${stat(item.twoPiece.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(item.twoPiece.min_value, item.twoPiece.stat_attribute_id)}–${format(item.twoPiece.max_value, item.twoPiece.stat_attribute_id)}` : 'NOT CONFIGURED'}
{item.talent?.name ?? 'NOT CONFIGURED'}{item.talent?.effects.length ?? 0} SCALED EFFECTS
{item.primaryStatIds.length} PRIMARY · {item.secondaryStatIds.length} SECONDARY{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}{#if item.pending}{:else}{/if}
+
{item.name}{item.pending ? `PROPOSED BY ${item.proposerName}` : 'SUPERIOR · HIGH-END'}
{ability(String(item.specialization_ability_id ?? ''))?.name ?? 'UNASSIGNED'}{ability(String(item.specialization_ability_id ?? '')) ? 'EXCLUSIVE ABILITY' : 'LEGACY RECORD'}
SUPHE{item.twoPiece ? `${stat(item.twoPiece.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(item.twoPiece.min_value, item.twoPiece.stat_attribute_id)}–${format(item.twoPiece.max_value, item.twoPiece.stat_attribute_id)}` : 'NOT CONFIGURED'}
{item.talent?.name ?? 'NOT CONFIGURED'}{item.talent?.effects.length ?? 0} SCALED EFFECTS
{item.primaryStatIds.length} PRIMARY · {item.secondaryStatIds.length} SECONDARY{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}{#if item.pending}{:else}{/if}
{:else}
NO SKILL CHIP SETS REGISTEREDAdd the first Superior and High-end Skill Chip definition.
{/each}
diff --git a/src/routes/isac/specializations/+page.server.ts b/src/routes/isac/specializations/+page.server.ts index c1f03b8..8cd73ae 100644 --- a/src/routes/isac/specializations/+page.server.ts +++ b/src/routes/isac/specializations/+page.server.ts @@ -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(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() }; } diff --git a/src/routes/isac/specializations/+page.svelte b/src/routes/isac/specializations/+page.svelte index 0bd77e7..d7d399c 100644 --- a/src/routes/isac/specializations/+page.svelte +++ b/src/routes/isac/specializations/+page.svelte @@ -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)}
-
ABILITY {slot} · {slot === 2 ? 'COMBAT SKILLS' : 'TREE + COMBAT SKILLS'}{ability?.name ?? 'NOT ASSIGNED'}

{ability?.description ?? 'Assign an active class ability to complete this focus.'}

+
+ ABILITY {slot} · {slot === 2 ? 'COMBAT SKILLS' : 'TREE + COMBAT SKILLS'} + {ability?.name ?? 'NOT ASSIGNED'} + {#if ability} + {abilityChipCount(ability)} / 3 SKILL CHIPS · {abilityChipCount(ability) === 3 ? 'READY' : 'INCOMPLETE'} + {/if} +

{ability?.description ?? 'Assign an active class ability to complete this focus.'}

+
{#if ability}{/if}
{/each} @@ -558,7 +566,7 @@ {#each data.abilities.filter((ability: any) => ability.specialization_id === selectedSpecialization.id) as ability}
-
{ability.name}{Number(ability.active) === 1 ? 'ACTIVE CLASS ABILITY' : 'ARCHIVED CLASS ABILITY'}
+
{ability.name}{Number(ability.active) === 1 ? `${abilityChipCount(ability)} / 3 SKILL CHIPS` : 'ARCHIVED CLASS ABILITY'}
{:else}