Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e67c3d4cdb | ||
|
|
4777a3abd5 | ||
|
|
46f64d37b6 | ||
|
|
7ca6cb8458 | ||
|
|
57068b61cd | ||
|
|
d9924a95b9 | ||
|
|
d09b6220ee | ||
|
|
c61b9832dd |
@@ -41,6 +41,10 @@ ORIGIN=https://dev.shd.build
|
||||
# accessing development directly at http://localhost:5173.
|
||||
PASSKEY_RP_ID=shd.build
|
||||
|
||||
# One to three comma-separated key-id:canonical-base64url-32-byte-key entries.
|
||||
# The first key is used for new MFA TOTP factors; retain old keys for rotation.
|
||||
MFA_TOTP_ENCRYPTION_KEYS=
|
||||
|
||||
# SMTP delivery for email verification and magic-link authentication.
|
||||
# Port 465 normally uses SMTP_SECURE=true. Port 587 normally uses false and STARTTLS.
|
||||
SMTP_HOST=
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ ENV NODE_ENV=production \
|
||||
TDR_GAME_ICON_STORAGE_PATH=/app/data/game-icons
|
||||
WORKDIR /app
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates curl gosu \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates curl fonts-dejavu-core gosu \
|
||||
&& useradd --uid 10001 --gid node --no-create-home --shell /usr/sbin/nologin shd-runtime \
|
||||
&& case "$TARGETARCH" in \
|
||||
amd64) infisical_sha256=53abc96a3bc872ade81efe156275bd30c53f9ee8b52965b0c9cb320a9c814fbc ;; \
|
||||
|
||||
@@ -32,8 +32,23 @@ npm run db:seed-users
|
||||
|
||||
The seed creates `Aewyn` and `Quartermaster` with the standard user role. It
|
||||
generates and displays strong temporary passwords once, then stores only salted
|
||||
Argon2id hashes. The separate bootstrap policy promotes the first authenticated
|
||||
account when no administrator exists.
|
||||
Argon2id hashes. `DEFAULT_ADMIN_PASSWORD` is the password variable name for
|
||||
`Aewyn`; it does not grant administrator authority.
|
||||
|
||||
Sign-in never grants administrator authority automatically. For local
|
||||
development, give the target account a verified email, enroll TOTP at
|
||||
`/account/security`, and save its one-time recovery codes. Stop the development
|
||||
server, then run:
|
||||
|
||||
```sh
|
||||
npm run admin:bootstrap -- <verified-email>
|
||||
```
|
||||
|
||||
The offline command requires an active TOTP factor and an unused recovery code,
|
||||
revokes the target account's sessions, and refuses to run when an active full
|
||||
administrator already exists. Restart the development server after it succeeds.
|
||||
See [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) for staging and production
|
||||
operation.
|
||||
|
||||
## Develop
|
||||
|
||||
|
||||
+116
-31
@@ -555,7 +555,7 @@ CREATE TABLE IF NOT EXISTS equipment_types (id TEXT PRIMARY KEY, name TEXT NOT N
|
||||
CREATE TABLE IF NOT EXISTS stat_definitions (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, value_kind TEXT NOT NULL, min_value REAL, max_value REAL, unit TEXT, active INTEGER NOT NULL DEFAULT 1);
|
||||
CREATE TABLE IF NOT EXISTS stat_equipment_types (stat_id TEXT NOT NULL REFERENCES stat_definitions(id), equipment_type_id TEXT NOT NULL REFERENCES equipment_types(id), PRIMARY KEY(stat_id, equipment_type_id));
|
||||
CREATE TABLE IF NOT EXISTS audit_log (id TEXT PRIMARY KEY, actor_id TEXT NOT NULL REFERENCES users(id), entity_type TEXT NOT NULL, entity_id TEXT NOT NULL, action TEXT NOT NULL, before_json TEXT, after_json TEXT, created_at TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, token_hash TEXT NOT NULL UNIQUE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, token_hash TEXT NOT NULL UNIQUE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL, aal INTEGER NOT NULL DEFAULT 1 CHECK(aal IN (1,2)), aal_verified_at TEXT NOT NULL, aal2_verified_at TEXT);
|
||||
CREATE TABLE IF NOT EXISTS roles (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, description TEXT);
|
||||
CREATE TABLE IF NOT EXISTS deleted_roles (role_id TEXT PRIMARY KEY, deleted_at TEXT NOT NULL, deleted_by TEXT NOT NULL REFERENCES users(id));
|
||||
CREATE TABLE IF NOT EXISTS permissions (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, description TEXT);
|
||||
@@ -591,6 +591,8 @@ CREATE TABLE IF NOT EXISTS login_events (
|
||||
session_ended_at TEXT,
|
||||
session_end_reason TEXT,
|
||||
sensitive_purged_at TEXT,
|
||||
aal INTEGER CHECK(aal IS NULL OR aal IN (1,2)),
|
||||
aal2_verified_at TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS user_emails (
|
||||
@@ -643,6 +645,60 @@ CREATE TABLE IF NOT EXISTS deleted_accounts (
|
||||
reason TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS login_events_user_created_idx ON login_events(user_id, created_at DESC);
|
||||
CREATE TABLE IF NOT EXISTS mfa_totp_factors (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
state TEXT NOT NULL CHECK(state IN ('pending','active','revoked')),
|
||||
encrypted_secret TEXT NOT NULL,
|
||||
encryption_key_id TEXT NOT NULL,
|
||||
issuer TEXT NOT NULL DEFAULT 'SHD.build',
|
||||
account_label TEXT NOT NULL,
|
||||
algorithm TEXT NOT NULL DEFAULT 'SHA1' CHECK(algorithm='SHA1'),
|
||||
digits INTEGER NOT NULL DEFAULT 6 CHECK(digits=6),
|
||||
period_seconds INTEGER NOT NULL DEFAULT 30 CHECK(period_seconds=30),
|
||||
last_counter INTEGER,
|
||||
failed_attempts INTEGER NOT NULL DEFAULT 0 CHECK(failed_attempts BETWEEN 0 AND 5),
|
||||
locked_until TEXT,
|
||||
expires_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
activated_at TEXT,
|
||||
revoked_at TEXT,
|
||||
CHECK((state='pending' AND expires_at IS NOT NULL AND activated_at IS NULL AND revoked_at IS NULL)
|
||||
OR (state='active' AND activated_at IS NOT NULL AND expires_at IS NULL AND revoked_at IS NULL)
|
||||
OR (state='revoked' AND revoked_at IS NOT NULL))
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS mfa_totp_factors_one_active_idx
|
||||
ON mfa_totp_factors(user_id) WHERE state='active';
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS mfa_totp_factors_one_pending_idx
|
||||
ON mfa_totp_factors(user_id) WHERE state='pending';
|
||||
CREATE INDEX IF NOT EXISTS mfa_totp_factors_expiry_idx
|
||||
ON mfa_totp_factors(state,expires_at) WHERE state='pending';
|
||||
CREATE TABLE IF NOT EXISTS mfa_recovery_codes (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
code_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
consumed_at TEXT,
|
||||
revoked_at TEXT,
|
||||
UNIQUE(user_id,code_hash)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS mfa_recovery_codes_available_idx
|
||||
ON mfa_recovery_codes(user_id,consumed_at,revoked_at);
|
||||
CREATE TABLE IF NOT EXISTS authentication_transactions (
|
||||
id TEXT PRIMARY KEY,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
provider TEXT CHECK(provider IS NULL OR provider IN ('password','discord','magic_link','passkey')),
|
||||
kind TEXT NOT NULL CHECK(kind IN ('login','step_up')),
|
||||
return_path TEXT NOT NULL CHECK(length(return_path) BETWEEN 1 AND 2048 AND substr(return_path,1,1)='/'),
|
||||
required_aal INTEGER NOT NULL DEFAULT 2 CHECK(required_aal=2),
|
||||
attempt_count INTEGER NOT NULL DEFAULT 0 CHECK(attempt_count BETWEEN 0 AND 5),
|
||||
expires_at TEXT NOT NULL,
|
||||
consumed_at TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS authentication_transactions_expiry_idx
|
||||
ON authentication_transactions(expires_at);
|
||||
INSERT OR IGNORE INTO roles(id,name,description) VALUES('role-data-editor','Data Editor','Access to ISAC game-data management');
|
||||
INSERT OR IGNORE INTO roles(id,name,description) VALUES('role-user-operator','User Operator','View and moderate SHD.build users');
|
||||
INSERT OR IGNORE INTO roles(id,name,description) VALUES('role-access-manager','Access Manager','Manage user roles and direct permissions');
|
||||
@@ -769,15 +825,12 @@ CREATE TABLE IF NOT EXISTS stat_attributes (
|
||||
weapon_min REAL,
|
||||
weapon_max REAL,
|
||||
weapon_reference_level INTEGER NOT NULL DEFAULT 50 CHECK(weapon_reference_level BETWEEN 1 AND 50),
|
||||
skill_chip_min REAL,
|
||||
skill_chip_max REAL,
|
||||
cap_value REAL,
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
CHECK((gear_min IS NULL AND gear_max IS NULL) OR (gear_min IS NOT NULL AND gear_max IS NOT NULL AND gear_min <= gear_max)),
|
||||
CHECK((weapon_min IS NULL AND weapon_max IS NULL) OR (weapon_min IS NOT NULL AND weapon_max IS NOT NULL AND weapon_min <= weapon_max)),
|
||||
CHECK((skill_chip_min IS NULL AND skill_chip_max IS NULL) OR (skill_chip_min IS NOT NULL AND skill_chip_max IS NOT NULL AND skill_chip_min <= skill_chip_max)),
|
||||
CHECK(cap_value IS NULL OR cap_value >= 0)
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS stat_attributes_name_unit_nocase_idx ON stat_attributes(name COLLATE NOCASE, unit);
|
||||
@@ -906,6 +959,7 @@ CREATE TABLE IF NOT EXISTS conditional_effect_outcomes (
|
||||
delivery_mode TEXT NOT NULL DEFAULT 'instant' CHECK(delivery_mode IN ('instant','continuous')),
|
||||
delivery_duration_seconds REAL CHECK(delivery_duration_seconds IS NULL OR delivery_duration_seconds>0),
|
||||
targeting_json TEXT,
|
||||
probability_percent REAL CHECK(probability_percent IS NULL OR (probability_percent>=0 AND probability_percent<=100)),
|
||||
min_state_value REAL,
|
||||
max_state_value REAL,
|
||||
description_slot INTEGER CHECK(description_slot IS NULL OR description_slot>=1),
|
||||
@@ -1168,14 +1222,30 @@ CREATE TABLE IF NOT EXISTS skill_chip_two_piece_bonuses (
|
||||
CHECK(max_value >= min_value)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS skill_chip_primary_options (
|
||||
skill_chip_set_id TEXT NOT NULL REFERENCES skill_chip_sets(id) ON DELETE CASCADE,
|
||||
stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id),
|
||||
PRIMARY KEY(skill_chip_set_id, stat_attribute_id)
|
||||
stat_attribute_id TEXT PRIMARY KEY REFERENCES stat_attributes(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS skill_chip_primary_quality_values (
|
||||
stat_attribute_id TEXT NOT NULL,
|
||||
quality TEXT NOT NULL CHECK(quality IN ('Standard','Specialized','Superior','High-end')),
|
||||
min_value REAL NOT NULL,
|
||||
max_value REAL NOT NULL,
|
||||
PRIMARY KEY(stat_attribute_id,quality),
|
||||
FOREIGN KEY(stat_attribute_id) REFERENCES skill_chip_primary_options(stat_attribute_id) ON DELETE CASCADE,
|
||||
CHECK(max_value>=min_value),
|
||||
CHECK(min_value>=0)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS skill_chip_secondary_options (
|
||||
skill_chip_set_id TEXT NOT NULL REFERENCES skill_chip_sets(id) ON DELETE CASCADE,
|
||||
stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id),
|
||||
PRIMARY KEY(skill_chip_set_id, stat_attribute_id)
|
||||
stat_attribute_id TEXT PRIMARY KEY REFERENCES stat_attributes(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS skill_chip_secondary_quality_values (
|
||||
stat_attribute_id TEXT NOT NULL,
|
||||
quality TEXT NOT NULL CHECK(quality IN ('Specialized','Superior','High-end')),
|
||||
min_value REAL NOT NULL,
|
||||
max_value REAL NOT NULL,
|
||||
PRIMARY KEY(stat_attribute_id,quality),
|
||||
FOREIGN KEY(stat_attribute_id) REFERENCES skill_chip_secondary_options(stat_attribute_id) ON DELETE CASCADE,
|
||||
CHECK(max_value>=min_value),
|
||||
CHECK(min_value>=0)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS skill_chip_talents (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -1232,19 +1302,21 @@ CREATE TABLE IF NOT EXISTS weapon_mod_quality_values (
|
||||
CHECK(primary_level_20_max>=primary_level_1_max)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS weapon_mod_secondary_options (
|
||||
weapon_mod_id TEXT NOT NULL REFERENCES weapon_mods(id) ON DELETE CASCADE,
|
||||
stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id),
|
||||
PRIMARY KEY(weapon_mod_id,stat_attribute_id)
|
||||
stat_attribute_id TEXT PRIMARY KEY REFERENCES stat_attributes(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS weapon_mod_secondary_quality_values (
|
||||
weapon_mod_id TEXT NOT NULL,
|
||||
stat_attribute_id TEXT NOT NULL,
|
||||
quality TEXT NOT NULL CHECK(quality IN ('Standard','Specialized','Superior','High-end')),
|
||||
min_value REAL NOT NULL,
|
||||
max_value REAL NOT NULL,
|
||||
PRIMARY KEY(weapon_mod_id,stat_attribute_id,quality),
|
||||
FOREIGN KEY(weapon_mod_id,stat_attribute_id) REFERENCES weapon_mod_secondary_options(weapon_mod_id,stat_attribute_id) ON DELETE CASCADE,
|
||||
CHECK(max_value>=min_value)
|
||||
quality TEXT NOT NULL CHECK(quality IN ('Specialized','Superior','High-end')),
|
||||
level_1_min REAL NOT NULL,
|
||||
level_1_max REAL NOT NULL,
|
||||
level_20_min REAL NOT NULL,
|
||||
level_20_max REAL NOT NULL,
|
||||
PRIMARY KEY(stat_attribute_id,quality),
|
||||
FOREIGN KEY(stat_attribute_id) REFERENCES weapon_mod_secondary_options(stat_attribute_id) ON DELETE CASCADE,
|
||||
CHECK(level_1_max>=level_1_min),
|
||||
CHECK(level_20_max>=level_20_min),
|
||||
CHECK(level_20_min>=level_1_min),
|
||||
CHECK(level_20_max>=level_1_max)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS weapon_mod_exotic_attributes (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -1292,19 +1364,21 @@ CREATE TABLE IF NOT EXISTS gear_mod_quality_values (
|
||||
CHECK(primary_level_20_max>=primary_level_1_max)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS gear_mod_secondary_options (
|
||||
gear_mod_id TEXT NOT NULL REFERENCES gear_mods(id) ON DELETE CASCADE,
|
||||
stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id),
|
||||
PRIMARY KEY(gear_mod_id,stat_attribute_id)
|
||||
stat_attribute_id TEXT PRIMARY KEY REFERENCES stat_attributes(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS gear_mod_secondary_quality_values (
|
||||
gear_mod_id TEXT NOT NULL,
|
||||
stat_attribute_id TEXT NOT NULL,
|
||||
quality TEXT NOT NULL CHECK(quality IN ('Standard','Specialized','Superior','High-end')),
|
||||
min_value REAL NOT NULL,
|
||||
max_value REAL NOT NULL,
|
||||
PRIMARY KEY(gear_mod_id,stat_attribute_id,quality),
|
||||
FOREIGN KEY(gear_mod_id,stat_attribute_id) REFERENCES gear_mod_secondary_options(gear_mod_id,stat_attribute_id) ON DELETE CASCADE,
|
||||
CHECK(max_value>=min_value)
|
||||
quality TEXT NOT NULL CHECK(quality IN ('Specialized','Superior','High-end')),
|
||||
level_1_min REAL NOT NULL,
|
||||
level_1_max REAL NOT NULL,
|
||||
level_20_min REAL NOT NULL,
|
||||
level_20_max REAL NOT NULL,
|
||||
PRIMARY KEY(stat_attribute_id,quality),
|
||||
FOREIGN KEY(stat_attribute_id) REFERENCES gear_mod_secondary_options(stat_attribute_id) ON DELETE CASCADE,
|
||||
CHECK(level_1_max>=level_1_min),
|
||||
CHECK(level_20_max>=level_20_min),
|
||||
CHECK(level_20_min>=level_1_min),
|
||||
CHECK(level_20_max>=level_1_max)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS specialization_progression_config (
|
||||
id TEXT PRIMARY KEY CHECK(id='default'),
|
||||
@@ -1404,6 +1478,17 @@ CREATE TABLE IF NOT EXISTS specialization_ability_attacks (
|
||||
CHECK(charge_policy<>'none' OR charge_count=1)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS specialization_abilities_specialization_idx ON specialization_abilities(specialization_id,name);
|
||||
CREATE TABLE IF NOT EXISTS specialization_ability_skill_chip_links (
|
||||
ability_id TEXT NOT NULL REFERENCES specialization_abilities(id) ON DELETE CASCADE,
|
||||
compatible_ability_id TEXT NOT NULL REFERENCES specialization_abilities(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY(ability_id,compatible_ability_id),
|
||||
CHECK(ability_id<compatible_ability_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS specialization_ability_skill_chip_links_compatible_idx
|
||||
ON specialization_ability_skill_chip_links(compatible_ability_id,ability_id);
|
||||
-- db-migrate.mjs installs insert/update triggers for active same-specialization
|
||||
-- endpoints and one-pair-per-ability enforcement. Trigger bodies cannot live in
|
||||
-- this semicolon-delimited schema file.
|
||||
CREATE TABLE IF NOT EXISTS specialization_ability_effects (
|
||||
id TEXT PRIMARY KEY,
|
||||
ability_id TEXT NOT NULL REFERENCES specialization_abilities(id) ON DELETE CASCADE,
|
||||
@@ -1628,7 +1713,7 @@ INSERT OR IGNORE INTO calculation_stages(id,revision_id,stage_key,label,operator
|
||||
INSERT OR IGNORE INTO calculation_stages(id,revision_id,stage_key,label,operator,display_order,locked_position)
|
||||
VALUES('calculation-stage-cycle-1','calculation-revision-1','firing_cycle','Firing cycle','firing_cycle',9,'last');
|
||||
CREATE TABLE IF NOT EXISTS game_data_icons (
|
||||
entity_type TEXT NOT NULL CHECK(entity_type IN ('talent', 'brand_set', 'os_protocol', 'skill_chip', 'specialization', 'specialization_ability', 'specialization_node')),
|
||||
entity_type TEXT NOT NULL CHECK(entity_type IN ('talent', 'brand_set', 'os_protocol', 'skill_chip', 'specialization', 'specialization_ability', 'specialization_node', 'weapon_mod_slot')),
|
||||
entity_id TEXT NOT NULL,
|
||||
storage_key TEXT NOT NULL UNIQUE,
|
||||
byte_size INTEGER NOT NULL CHECK(byte_size > 0),
|
||||
|
||||
@@ -140,6 +140,10 @@ describe('container runtime startup', () => {
|
||||
writeFile(join(temporaryRoot, 'chown'), ownershipStub, { mode: 0o755 }),
|
||||
writeExport(appExport, [
|
||||
{ key: 'APP_ORIGIN', value: 'https://exported.invalid' },
|
||||
{
|
||||
key: 'MFA_TOTP_ENCRYPTION_KEYS',
|
||||
value: 'primary:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
||||
},
|
||||
{ key: 'DISCORD_CLIENT_ID', value: 'exported-discord-client' },
|
||||
{ key: 'DISCORD_CLIENT_SECRET', value: 'exported-discord-secret' },
|
||||
]),
|
||||
@@ -218,6 +222,8 @@ describe('container runtime startup', () => {
|
||||
);
|
||||
expect(childEnvironment).toMatchObject({
|
||||
APP_ORIGIN: 'https://stage.shd.build',
|
||||
MFA_TOTP_ENCRYPTION_KEYS:
|
||||
'primary:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
||||
DISCORD_CLIENT_ID: 'exported-discord-client',
|
||||
DISCORD_CLIENT_SECRET: 'exported-discord-secret',
|
||||
ORIGIN: 'https://stage.shd.build',
|
||||
|
||||
+81
-3
@@ -98,9 +98,81 @@ remove the old dotenv private keys and `SCHEDULER_TOKEN_PRODUCTION` from Gitea,
|
||||
and delete local and hosted dotenv key material. Removing encrypted files from
|
||||
the current Git tree does not revoke values stored in Git history.
|
||||
|
||||
The staging container disables default-user seeding. On a brand-new staging
|
||||
database, the existing bootstrap policy grants Admin to the first real account
|
||||
that signs in instead of writing generated credentials into deployment logs.
|
||||
## Authentication bootstrap and MFA secrets
|
||||
|
||||
Set the required app-only `MFA_TOTP_ENCRYPTION_KEYS` secret under `/app` in the
|
||||
`dev`, `staging`, and `prod` Infisical environments before starting each app.
|
||||
The value contains one to three comma-separated, unique
|
||||
`key-id:canonical-base64url-32-byte-key` entries. Each key is a random 32-byte
|
||||
value encoded as canonical Base64URL. The first entry is the active write key;
|
||||
following entries are retained decryption keys for older TOTP factors. Do not
|
||||
put this secret under `/shared/scheduler`, in a Gitea variable, in the image, or
|
||||
in a deployment log. A missing, malformed, duplicate, or overlong keyring fails
|
||||
app runtime validation.
|
||||
|
||||
Rotate the keyring without losing access to existing factors:
|
||||
|
||||
1. Generate a new random 32-byte key in the approved secret-management process
|
||||
and assign it a new key ID. Do not copy the value into a document or shell
|
||||
history.
|
||||
2. Prepend the new `key-id` entry and retain the old entries in Infisical.
|
||||
3. Deploy or restart the app, then verify an existing TOTP sign-in and a new
|
||||
enrollment. A successful verification rewraps that factor under the first
|
||||
key.
|
||||
4. Retain each old key until every factor encrypted with it has been rewrapped
|
||||
and verified. Never remove old entries merely because new enrollments use the
|
||||
new key; losing a required old key prevents decryption of those factors.
|
||||
|
||||
TOTP enrollment issues ten recovery codes and displays them once. The database
|
||||
stores only their domain-separated hashes. The account owner must save the codes
|
||||
in an offline password manager or another protected offline record; do not put
|
||||
their values in Infisical, logs, tickets, backups, audit snapshots, or this
|
||||
documentation. Each code works once. Regenerating codes immediately revokes all
|
||||
unused previous codes and displays a replacement set once. Disabling TOTP also
|
||||
revokes its recovery codes.
|
||||
|
||||
Sign-in never grants administrator authority automatically. Before offline
|
||||
elevation, the target administrator must have a verified email, an active TOTP
|
||||
factor, and at least one unused recovery code. The target enrolls TOTP at
|
||||
`/account/security` while the app is running. Then stop every app and scheduler
|
||||
service that can access the database and run the environment-specific command.
|
||||
The command requires that no active full administrator already exists, assigns
|
||||
the canonical administrator role, revokes the target's sessions, and records
|
||||
`role_management / administrator.bootstrapped`.
|
||||
|
||||
For local development:
|
||||
|
||||
```sh
|
||||
npm run admin:bootstrap -- <verified-email>
|
||||
```
|
||||
|
||||
For staging, from `/opt/shd-build-stage`:
|
||||
|
||||
```sh
|
||||
docker compose --env-file .env.deploy -f compose.staging.yml stop scheduler app
|
||||
docker compose --env-file .env.deploy -f compose.staging.yml run --rm --no-deps \
|
||||
app node scripts/admin-bootstrap.mjs <verified-email>
|
||||
docker compose --env-file .env.deploy -f compose.staging.yml up -d app scheduler
|
||||
```
|
||||
|
||||
For production, from `/opt/shd-build-production`:
|
||||
|
||||
```sh
|
||||
docker compose --env-file .env.deploy -f compose.production.yml stop scheduler app
|
||||
docker compose --env-file .env.deploy -f compose.production.yml run --rm --no-deps \
|
||||
app node scripts/admin-bootstrap.mjs <verified-email>
|
||||
docker compose --env-file .env.deploy -f compose.production.yml up -d app scheduler
|
||||
```
|
||||
|
||||
The script requires exactly one active, unblocked account with a verified email,
|
||||
an active TOTP factor, and an unused recovery code. It exits without changing
|
||||
roles when an active full administrator already exists. The Compose commands
|
||||
restart their services; restart the local development server after its command.
|
||||
The target must sign in again because its old sessions were revoked.
|
||||
|
||||
The staging container disables default-user seeding. A first sign-in never
|
||||
receives Admin; use the offline flow above after a new database or an initial
|
||||
data bootstrap.
|
||||
|
||||
## Gitea Actions runner
|
||||
|
||||
@@ -388,6 +460,12 @@ import. The importer verifies the bundle target, creates a production safety
|
||||
backup, and refuses to replace a production database containing users, builds,
|
||||
audit events, or proposals. Remove the remote host copy after verification.
|
||||
|
||||
If the imported database has no active full administrator, complete the offline
|
||||
administrator flow with the production Compose command in
|
||||
[Authentication bootstrap and MFA secrets](#authentication-bootstrap-and-mfa-secrets)
|
||||
before reopening the site. Do not rely on the first production sign-in for
|
||||
elevation.
|
||||
|
||||
### Promote one News article
|
||||
|
||||
Export a locally authored News article by ID or slug while the development
|
||||
|
||||
+255
-107
@@ -341,40 +341,58 @@ squeezed beside long module headings on mobile.
|
||||
### Command Grid builder
|
||||
|
||||
The production builder uses Command Grid as its loadout workbench. Keep the
|
||||
entire build visible in a dense matrix while one connected editor configures the
|
||||
selected cell. The full-width Specialization and Focus cell is the root choice.
|
||||
Its three supplied abilities appear directly below it, followed by two weapons,
|
||||
six gear positions, and one full-width OS Protocol.
|
||||
entire build visible in a compact matrix while one connected editor configures
|
||||
the selected cell. The editor uses two lanes when its container has enough
|
||||
width. Equipment identity and base values occupy the first lane; allocations,
|
||||
rolls, Talents, and upgrades occupy the second. The lanes stack when either
|
||||
would become too narrow. The full-width Specialization and Focus cell is the
|
||||
root choice. Its three supplied abilities appear directly below it, followed by
|
||||
two weapons, six gear positions, and one full-width OS Protocol.
|
||||
|
||||
Matrix cells summarize selected Talent names, Attribute names and values, Brand
|
||||
Sets, or Skill Chips. Do not replace those choices with configuration counts.
|
||||
Selecting an ability opens its three attached Skill Chip positions. Selecting
|
||||
equipment opens the searchable real-data catalog and its compatible
|
||||
configuration controls. Do not display a prototype option that the current
|
||||
loadout schema cannot save.
|
||||
Matrix cells show the slot and equipped item at rest. The selected cell reveals
|
||||
its compact Talent, Attribute, Brand Set, or Skill Chip summary. This
|
||||
progressive disclosure keeps the matrix scannable without replacing configured
|
||||
choices with counts. Selecting an ability opens its three attached Skill Chip
|
||||
positions. Selecting equipment opens the searchable real-data catalog and its
|
||||
compatible configuration controls. Large Talent pools show the selected Talent
|
||||
at rest and open their search grid through an explicit Change action. Do not
|
||||
display a prototype option that the current loadout schema cannot save.
|
||||
|
||||
Calculation readiness belongs at the point of resolution. Mark an incomplete
|
||||
Builder cells use one leading icon track. Specializations, abilities, Skill
|
||||
Chips, and OS Protocols use registered artwork; weapons and gear use static
|
||||
category or slot fallbacks when no item artwork exists. Skill Chip slots keep
|
||||
the icon beside the set name and quality so empty and configured slots share one
|
||||
scan path. Visible builder labels stay short; full validation text remains
|
||||
available through the cell's accessible name or title.
|
||||
|
||||
Each equipped gear or weapon Talent exposes two adjacent icon choices for Tier I
|
||||
and Tier II. Both choices reuse the Talent artwork; Tier II applies a restrained
|
||||
warm recolor so players can distinguish the pair before reading the labels.
|
||||
Every tile retains `T1` or `T2`, its full tier name, and selected or unavailable
|
||||
text. Disable Tier II when the canonical Talent has no authored Tier II data.
|
||||
Weapon equipment tier and Talent tier are independent selections.
|
||||
|
||||
Equipment readiness belongs at the point of resolution. Mark an incomplete
|
||||
matrix cell with a compact red text indicator and status rail, then let the cell
|
||||
open the editor that resolves it. The live calculation rail summarizes blocked
|
||||
output in one compact row. Do not repeat every missing slot in a large error
|
||||
panel. Keep system-owned blockers, such as an unpublished calculation profile,
|
||||
visible in that compact summary because the player cannot resolve them from a
|
||||
loadout cell.
|
||||
open the editor that resolves it. The live totals rail summarizes deterministic
|
||||
equipped-item totals. Separate panels label conditional Talent outcomes, weapon
|
||||
and Skill DPS, encounter assumptions, activation profiles, rotations, and
|
||||
simulation as `COMING SOON`; they do not report calculation-profile blockers as
|
||||
current player errors.
|
||||
|
||||
The builder uses three workbench columns on wide screens, moves the live summary
|
||||
below the matrix and editor at intermediate widths, and stacks all three regions
|
||||
on narrow screens. Every grid track and editor child must accept content
|
||||
shrinkage. At 560px and below, stack attribute picker/value pairs and section
|
||||
headings; keep the command actions in two equal columns. Validation markers use
|
||||
short visible labels while their full messages remain available to assistive
|
||||
technology and pointer users.
|
||||
The builder uses two primary workbench columns on wide screens: the compact
|
||||
matrix and the two-lane editor. The live summary spans both columns below them.
|
||||
Narrow screens stack the matrix, editor, and summary. Every grid track and
|
||||
editor child must accept content shrinkage. At 560px and below, stack Attribute
|
||||
picker/value pairs and section headings; keep the command actions in two equal
|
||||
columns. Validation markers use short visible labels while their full messages
|
||||
remain available to assistive technology and pointer users.
|
||||
|
||||
Use `--shd-builder-canvas` for recessed equipment fields. Use
|
||||
`--shd-builder-selection` with `--shd-builder-selection-border` for the selected
|
||||
cell. Preserve button semantics, `aria-pressed`, focus-visible outlines, pressed
|
||||
feedback, and textual readiness states. At narrower desktop widths, keep the
|
||||
matrix and editor together and move live results below them. On mobile, stack
|
||||
the matrix, editor, and live results in that order.
|
||||
feedback, and textual readiness states. On mobile, stack the matrix, editor, and
|
||||
live results in that order.
|
||||
|
||||
The command header contains the required build name, local and account save
|
||||
actions, account visibility, and optional sharing notes. Sharing notes include a
|
||||
@@ -1572,13 +1590,42 @@ an icon in every option.
|
||||
|
||||
### Skill Chip data console
|
||||
|
||||
Skill Chip editing uses the dense ISAC staged-definition pattern: identity and
|
||||
icon, 2-piece numeric bonus, mutually exclusive primary and secondary roll
|
||||
pools, then 3-piece talent. Superior blue and High-end yellow identify the
|
||||
supported qualities; reserve orange for actions and the current interactive
|
||||
state. A four-step segmented preview shows the linear zero-to-three High-end
|
||||
interpolation without duplicate values. At narrow widths, stack roll-pool and
|
||||
talent columns, use a two-by-two preview grid, and retain 44-pixel targets.
|
||||
Place two shared configuration cards above the Skill Chip definition registry.
|
||||
The `SHARED PRIMARY PROFILE` card contains the fixed Skill Duration, Skill
|
||||
Radius, Skill Cooldown Recovery, and Skill Intensity rows, with direct minimum
|
||||
and maximum fields for Standard, Specialized, Superior, and High-end. Its
|
||||
summary shows the configured count for each quality and the `NOT YET DEFINED`
|
||||
calculation state. The `SHARED SECONDARY POOL` card contains repeatable
|
||||
searchable Attribute rows with direct minimum and maximum fields for
|
||||
Specialized, Superior, and High-end. Its summary shows each quality's fixed
|
||||
capacity and the shared option count. Each card opens its own editor and closes
|
||||
that editor after a successful save. Validation keeps the affected editor open
|
||||
with its values intact. Successful saves append
|
||||
`game_data/skill_chip_primary_profile.updated` or
|
||||
`game_data/skill_chip_secondary_pool.updated`.
|
||||
|
||||
State the fixed secondary capacities in the pool card and editor: Standard zero,
|
||||
Specialized one, Superior two, and High-end two. Skill Chip fields never show an
|
||||
item-level control or level-based preview. Quality labels use the global green,
|
||||
blue, purple, and yellow treatments; orange remains reserved for actions and
|
||||
selection. On narrow screens, profile summaries stack, endpoint matrices scroll
|
||||
inside their cards, and actions retain 44-pixel targets.
|
||||
|
||||
Definition editors follow the dense staged pattern: ability assignment, identity
|
||||
and icon, one per-set 2-piece bonus, then the optional 3-piece Talent. Place the
|
||||
Talent assignment control before one disclosure that contains the entire Talent
|
||||
workspace: identity, description, quality-mix preview, and
|
||||
`MechanicProgramEditor`. The disclosure summary shows the configured Talent name
|
||||
or `TALENT WORKSPACE`, the quality-scaled outcome count, and an explicit
|
||||
`EXPAND` or `COLLAPSE` action. Existing Talents start collapsed when an operator
|
||||
opens a definition. Assigning a Talent or receiving a Talent validation error
|
||||
opens the workspace. Native disclosure semantics expose the expanded state, keep
|
||||
draft state mounted while collapsed, and provide a 44-pixel mobile target.
|
||||
|
||||
Every equipped chip quality counts toward the two-piece threshold. The set-bonus
|
||||
preview uses four steps for zero through three equipped High-end chips and the
|
||||
formula `minimum + (maximum − minimum) × h / 3`; only High-end chips increase
|
||||
`h`. Do not duplicate calculated values as editable fields.
|
||||
|
||||
### ISAC record identity controls
|
||||
|
||||
@@ -1707,10 +1754,10 @@ cards and augment editor cards reduce to a single column while retaining
|
||||
|
||||
The builder is a three-rail tactical workbench: compact equipment positions on
|
||||
the left, the currently selected item editor in the center, and a sticky live
|
||||
calculation summary on the right. At medium widths the summary becomes a
|
||||
full-width lower rail; on mobile all three rails stack, with equipment first,
|
||||
editing second, and results third. Equipment positions remain explicit even when
|
||||
empty so loadout completeness is always visible.
|
||||
totals summary on the right. At medium widths the summary becomes a full-width
|
||||
lower rail; on mobile all three rails stack, with equipment first, editing
|
||||
second, and results third. Equipment positions remain explicit even when empty
|
||||
so players can see loadout completeness.
|
||||
|
||||
Use the shared `BuilderPicker` for builder catalogs and Attribute pools. It
|
||||
combines an always-visible current selection with a searchable overlay, icon or
|
||||
@@ -1721,11 +1768,11 @@ such as the single-exotic-weapon rule are explained rather than hidden.
|
||||
Numeric instance values display their authoritative minimum and maximum beside
|
||||
the field and clamp to that range on completion. Always-on item rolls, fixed
|
||||
bonuses, inherited profiles, enabled augments, and activated set bonuses feed
|
||||
the live totals immediately. Conditional mechanics state their calculation mode
|
||||
and readiness. Maximum Potential uses their best-case state; Custom Scenario
|
||||
uses the source-defined advanced assumptions saved with the loadout. Exclude a
|
||||
mechanic that lacks a required assumption and label it `REQUIRES ASSUMPTIONS`.
|
||||
This distinction must remain clear without relying on color.
|
||||
the live totals immediately. Current Builder and shared Build pages stop at
|
||||
these deterministic equipped-item totals. Conditional Talent outcomes, weapon
|
||||
and Skill DPS, encounter assumptions, activation profiles, rotations, and
|
||||
simulation remain planned. Render those capabilities as separate `COMING SOON`
|
||||
panels, with visible text rather than color alone.
|
||||
|
||||
Loadout summaries name selected Talents, Attributes and their exact values,
|
||||
nodes, and Skill Chips. Do not replace these selections with configured item
|
||||
@@ -1808,6 +1855,15 @@ same `weaponBaseRange` curve as the builder; never label the configured base
|
||||
endpoints as level-50 values. Weapon Talent 1 and Talent 2 pools use separate
|
||||
clickable icon rails and the standard Talent quick view.
|
||||
|
||||
Show each weapon's named Mod slots and slot pictograms between its performance
|
||||
facts and Talent pools. Native T0 weapons display the complete T1/T2 slot set.
|
||||
Mark the slot that is absent at T0 with the blue information state and an
|
||||
explicit `T1+` label so the unlock does not rely on color alone. Other slots use
|
||||
the neutral telemetry treatment. If ISAC has no slot data, show
|
||||
`MOD SLOT DATA NOT CONFIGURED`. Do not show the weapon-category bonus count as
|
||||
`BASE BONUSES`; that identical count does not help players compare weapon
|
||||
definitions.
|
||||
|
||||
Record cards may end with contextual actions that connect related public
|
||||
registries. Actions use the compact orange tactical-button treatment and encode
|
||||
their destination filter in the URL. Brand cards provide a “find Gear” action
|
||||
@@ -1920,10 +1976,33 @@ selector:
|
||||
- `PUBLIC` is accessible by direct link and included in the public archive.
|
||||
|
||||
Keep visibility adjacent to the account-save action and explain its selected
|
||||
state in plain language. Show a share-page action only after a non-private build
|
||||
has been saved. Public and unlisted build pages are read-only tactical
|
||||
summaries; owner editing returns to the builder. On narrow layouts, persistence
|
||||
controls and shared-loadout columns stack vertically.
|
||||
state in plain language. Show Build Uplink after an Agent saves a Build. Public
|
||||
and unlisted Builds expose its active controls; private Builds show the locked
|
||||
state. Public and unlisted Build pages are read-only tactical summaries; owner
|
||||
editing returns to the builder.
|
||||
|
||||
Build Uplink uses a compact tactical control surface. Use one bordered dark
|
||||
panel with a raised telemetry header, a textual `PUBLIC LINK READY` or
|
||||
`UNLISTED LINK READY` state, and a small orange signal marker. The primary
|
||||
`SHARE BUILD` action opens the native Web Share sheet when the browser supports
|
||||
it. Keep `COPY LINK` visible as a separate explicit action for every eligible
|
||||
Build. When Web Share is unavailable, promote Copy Link to the primary action
|
||||
instead of presenting a control that cannot run. Supporting copy may state that
|
||||
Discord renders the Build preview after an Agent posts the link. It must not
|
||||
imply that SHD.build posts to Discord or operates a bot.
|
||||
|
||||
Private and unsaved Builds use the locked Uplink state. Show `PRIVATE BUILD` or
|
||||
`UNSAVED BUILD`, `PUBLIC LINK UNAVAILABLE`, and a short instruction to change
|
||||
visibility or save. Render no active Share or Copy Link control in that state.
|
||||
Do not expose a crawler-preview action or suggest that owner access makes a
|
||||
private Build shareable.
|
||||
|
||||
Uplink actions retain textual pending, success, cancellation, unsupported, and
|
||||
failure feedback. They use at least 44-pixel targets, orange focus-visible
|
||||
treatment, pressed feedback, and disabled duplicate-action protection. Stack
|
||||
Share above Copy Link at phone widths and remove pressed motion under
|
||||
`prefers-reduced-motion`. On narrow layouts, persistence controls and
|
||||
shared-loadout columns also stack vertically.
|
||||
|
||||
### Tiered compatibility editors
|
||||
|
||||
@@ -1947,18 +2026,26 @@ mode with weapon icons and retained accessible names. Show configured versus
|
||||
required slot counts in text. Disabled tiers remain visibly unavailable and must
|
||||
not submit slot assignments.
|
||||
|
||||
Quality-scaled registry editors use a compact matrix when the same numeric shape
|
||||
repeats across all four quality levels. Apply global quality colors only to the
|
||||
quality labels, retain textual quality names, and keep numeric fields neutral.
|
||||
On narrow screens the matrix may scroll horizontally while its surrounding
|
||||
editor and previews stack.
|
||||
Regular Weapon Mod definitions use a compact Level 1 and Level 20 primary-range
|
||||
matrix for Standard, Specialized, Superior, and High-end quality. Apply global
|
||||
quality colors only to the quality labels, retain textual quality names, and
|
||||
keep numeric fields neutral. On narrow screens the matrix may scroll
|
||||
horizontally while its surrounding editor and previews stack. Primary core type
|
||||
remains builder-selected and appears as the three always-available core icons
|
||||
rather than a persisted ISAC choice. Individual regular Weapon Mod editors also
|
||||
retain triggerless Mod Effects with one or two Attribute references and fixed
|
||||
per-quality values.
|
||||
|
||||
When two item slots draw from the same canonical option pool, edit that pool
|
||||
once rather than rendering two duplicated selectors. Weapon Mods use a
|
||||
repeatable secondary-attribute row: one searchable attribute reference plus four
|
||||
quality range pairs. Show that the builder selects two distinct options from the
|
||||
pool. Primary core type is likewise builder-selected and is displayed as the
|
||||
three always-available core icons rather than a persisted ISAC choice.
|
||||
Configure one global secondary Attribute pool above the Weapon Mod registry.
|
||||
Each repeatable row contains one searchable Attribute reference and Level 1 and
|
||||
Level 20 minimum and maximum fields for Specialized, Superior, and High-end.
|
||||
Values between those endpoints use linear interpolation. Do not render Standard
|
||||
range fields because Standard has no secondary slot. State the fixed capacity in
|
||||
the pool editor and builder: Standard zero, Specialized one, Superior two, and
|
||||
High-end two. The builder requires distinct selections. Individual Weapon Mod
|
||||
editors must not repeat or submit pool data. A successful pool save uses the
|
||||
standard shared-profile lifecycle and creates the dedicated
|
||||
`game_data/weapon_mod_secondary_pool.updated` audit event.
|
||||
|
||||
Exotic Weapon Mod status is a separate single-icon toggle using the exotic icon
|
||||
and the standard pressed, focus-visible, and selected states. Selecting it must
|
||||
@@ -1970,12 +2057,20 @@ state panel visible so the change in data semantics is unmistakable.
|
||||
|
||||
### Gear Mod registry
|
||||
|
||||
Gear Mods reuse the compact quality matrix, level preview, and pooled
|
||||
secondary-attribute rows established by Weapon Mods. Do not render exotic,
|
||||
Gear Mods reuse the compact Level 1 and Level 20 primary-range matrix and level
|
||||
preview established by regular Weapon Mods. Do not render exotic,
|
||||
weapon-category, slot-type, or Mod Effect controls in this simpler editor.
|
||||
Present the five primary options as an always-visible informational band because
|
||||
the actual primary is selected on the build instance, not persisted on the
|
||||
canonical definition.
|
||||
the build instance selects the primary; the canonical definition does not store
|
||||
that selection.
|
||||
|
||||
Configure the Gear Mod family’s global secondary Attribute pool above the
|
||||
registry, separate from individual Gear Mod editors. Use the same three-quality
|
||||
endpoint rows, linear interpolation, and distinct-selection rule as the Weapon
|
||||
Mod pool. Capacity is zero for Standard, one for Specialized, and two each for
|
||||
Superior and High-end. Individual Gear Mod edits must not repeat or submit this
|
||||
pool. A successful pool save creates the dedicated
|
||||
`game_data/gear_mod_secondary_pool.updated` audit event.
|
||||
|
||||
Use explicit textual tier capacity wherever Gear tier is summarized:
|
||||
`1 MOD SLOT` for T0 and `2 MOD SLOTS` for T1/T2. On narrow screens the identity
|
||||
@@ -2206,6 +2301,13 @@ 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.
|
||||
|
||||
The ability editor may pair the open ability with one active ability from the
|
||||
same specialization for Skill Chip sharing. Present this as an explicit optional
|
||||
relationship, exclude the current ability, and disable abilities that already
|
||||
belong to another pair. Do not infer compatibility from names or Focus
|
||||
membership. Saving either side updates the shared pair and includes it in the
|
||||
ability audit snapshot. Archiving either ability clears the pair.
|
||||
|
||||
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
|
||||
@@ -2302,6 +2404,16 @@ applied to the stored value. Restoration outcomes also expose an `INSTANT` or
|
||||
field and describes the configured value as the total delivered across that
|
||||
duration; switching back to instant clears the duration.
|
||||
|
||||
Target-scoped direct-damage outcomes offer `HIT-TARGET EXPLOSION` in the Area
|
||||
Targeting control. Selecting it creates an event-target-centered enemy area with
|
||||
a five-meter starting radius, reveals the required radius field, and shows a
|
||||
text readout for the center, valid-target policy, inclusive boundary, and
|
||||
runtime evidence requirement. Only `ON EVENT` and `AFTER EVENT` remain available
|
||||
for this mode; selecting it from another phase moves the outcome to
|
||||
`AFTER EVENT`. Changing the outcome kind or target scope clears an incompatible
|
||||
area definition. Keep `ENEMIES IN RADIUS · LEGACY` visible for saved descriptive
|
||||
radius metadata, but do not present that mode as calculated explosion fan-out.
|
||||
|
||||
Action Family recipes are optional authoring starters. Present the recipe
|
||||
catalog as a searchable assignment with only the parameters required by the
|
||||
selected family. `ADD RECIPE BRANCH` copies typed branches into the current
|
||||
@@ -2309,14 +2421,14 @@ source program, where authors may edit its exposed branch, state, and outcome
|
||||
controls. The recipe label records provenance only. `REPLACE PROGRAM` requires
|
||||
an inline confirmation that states all existing branches will be discarded.
|
||||
|
||||
Builder Controls define source-specific advanced scenario assumptions. Authors
|
||||
choose toggle, slider, or numeric input and provide a label, Agent guidance,
|
||||
default value, best-case value, and numeric bounds when applicable. Group these
|
||||
controls behind the builder's advanced scenario surface. Maximum Potential uses
|
||||
best-case values. Custom Scenario uses saved Agent values. A required cadence or
|
||||
environmental value with no supplied assumption keeps the mechanic out of the
|
||||
calculation and displays `REQUIRES ASSUMPTIONS`; the editor and runtime never
|
||||
invent a cadence.
|
||||
Builder Controls define source-specific scenario assumptions for the planned
|
||||
player simulation. ISAC authors choose toggle, slider, or numeric input and
|
||||
provide a label, Agent guidance, default value, best-case value, and numeric
|
||||
bounds when applicable. The current player UI does not render these controls or
|
||||
read their saved values. It uses an explicit `COMING SOON` panel for the future
|
||||
advanced scenario surface. The authored contract still requires the runtime to
|
||||
report `REQUIRES ASSUMPTIONS` when a required cadence or environmental value is
|
||||
missing; the editor and runtime never invent a cadence.
|
||||
|
||||
Use `--shd-model-surface` and `--shd-model-border` for mechanic programs,
|
||||
calculation revisions, and field calibration. The blue information rail marks
|
||||
@@ -2482,9 +2594,17 @@ anchor scrolling and sticky-state transitions.
|
||||
|
||||
#### Builder calculation modes
|
||||
|
||||
The loadout builder places one required two-option `SegmentedChoice` above
|
||||
combat assumptions. The persisted `maximum` and `custom` values use these
|
||||
visible labels:
|
||||
The current Builder and shared Build pages do not expose calculation modes or
|
||||
combat assumptions. They show deterministic equipped-item totals and reserve
|
||||
conditional Talent outcomes, weapon and Skill DPS, encounter assumptions,
|
||||
activation profiles, rotations, and simulation behind explicit `COMING SOON`
|
||||
panels.
|
||||
|
||||
Loadout schema version 6 retains `maximum`, `custom`, conditional state,
|
||||
encounter-assumption, and per-ability activation fields. Version 4 is obsolete
|
||||
prototype data rather than a compatibility format. The current player UI ignores
|
||||
the simulation fields. When player simulation ships, one required two-option
|
||||
`SegmentedChoice` will use these visible labels:
|
||||
|
||||
- `MAXIMUM POTENTIAL` calculates a configured ceiling. It applies conditional
|
||||
effects at peak state and treats selected abilities as firing on cooldown
|
||||
@@ -2492,40 +2612,42 @@ visible labels:
|
||||
- `CUSTOM SCENARIO` exposes encounter assumptions and source-defined advanced
|
||||
controls for a player-defined scenario.
|
||||
|
||||
New loadouts select `MAXIMUM POTENTIAL`. The interface labels Maximum results as
|
||||
a modeled ceiling so players do not read them as typical encounter performance.
|
||||
Maximum hides granular assumption fields without deleting their saved values.
|
||||
Returning to Custom restores those values.
|
||||
The planned interface selects `MAXIMUM POTENTIAL` for new loadouts and labels
|
||||
its results as a modeled ceiling. Maximum will hide granular assumption fields
|
||||
without deleting their saved values. Returning to Custom will restore those
|
||||
values.
|
||||
|
||||
Custom mode gives each unlocked conditional effect three state controls:
|
||||
The planned Custom mode gives each unlocked conditional effect three state
|
||||
controls:
|
||||
|
||||
- `OFF` excludes the effect.
|
||||
- `PEAK` applies its configured maximum stacks.
|
||||
- `ENCOUNTER` models stack buildup and expiry from weapon RPM, magazine size,
|
||||
reload time, hit rate, firing activity, and encounter duration.
|
||||
|
||||
Users may override encounter assumptions on one effect without changing the
|
||||
global Custom profile. Open the advanced scenario surface to show only the
|
||||
toggle, slider, and numeric controls defined by that effect's owning source. The
|
||||
effect card shows uptime, activation count, and applied stack state beside the
|
||||
mode. A mechanic that needs an undefined cadence or another missing scenario
|
||||
input displays `REQUIRES ASSUMPTIONS`; the builder withholds its calculated
|
||||
contribution until the author or player supplies that input.
|
||||
The planned advanced scenario surface will let users override one effect without
|
||||
changing the global Custom profile. It will show only the toggle, slider, and
|
||||
numeric controls defined by that effect's owning source. The effect card will
|
||||
show uptime, activation count, and applied stack state beside the mode. A
|
||||
mechanic that needs an undefined cadence or another missing scenario input will
|
||||
display `REQUIRES ASSUMPTIONS`; the simulation will withhold its contribution
|
||||
until the author or player supplies that input.
|
||||
|
||||
Maximum and Custom use radio semantics, an explicit selected label, keyboard
|
||||
arrow navigation, and the standard orange selected rail. Results repeat the
|
||||
active calculation mode in text. On mobile, the global mode control and Custom
|
||||
assumption fields stack into full-width targets of at least 44 pixels. Mode
|
||||
changes use the standard pressed feedback; reduced-motion preferences remove
|
||||
transitions without hiding the state change.
|
||||
The future Maximum and Custom control will use radio semantics, an explicit
|
||||
selected label, keyboard arrow navigation, and the standard orange selected
|
||||
rail. Results will repeat the active calculation mode in text. On mobile, the
|
||||
global mode control and Custom assumption fields will stack into full-width
|
||||
targets of at least 44 pixels. Mode changes will use the standard pressed
|
||||
feedback; reduced-motion preferences will remove transitions without hiding the
|
||||
state change.
|
||||
|
||||
Combat result panels show normal, critical, headshot, and critical-headshot
|
||||
values with burst, sustained, and encounter DPS. Conditional stack telemetry
|
||||
lists the mode, uptime, activations, average and peak stacks, ending stacks,
|
||||
buildup time, and maintained state. A result with missing equipment, Attribute
|
||||
bindings, rules, or assumptions displays `INSUFFICIENT DATA` and a concrete
|
||||
remediation list. The interface withholds combat numbers until it has the
|
||||
required inputs.
|
||||
The future combat result panels will show normal, critical, headshot, and
|
||||
critical-headshot values with burst, sustained, and encounter DPS. Conditional
|
||||
stack telemetry will list the mode, uptime, activations, average and peak
|
||||
stacks, ending stacks, buildup time, and maintained state. A result with missing
|
||||
equipment, Attribute bindings, rules, or assumptions will display
|
||||
`INSUFFICIENT DATA` and a concrete remediation list. Until this interface ships,
|
||||
the player pages show `COMING SOON` instead of combat numbers.
|
||||
|
||||
### Specialization loadout composer
|
||||
|
||||
@@ -2533,24 +2655,36 @@ The builder presents Specialization and Focus as the first loadout decision. The
|
||||
Focus sets the primary Attribute, progression tree, and three ordered abilities.
|
||||
Its identity combines Specialization artwork and the primary Attribute mark.
|
||||
Each ability panel contains three fixed Skill Chip positions. A chip picker
|
||||
shows definitions assigned to that ability and excludes definitions assigned
|
||||
elsewhere.
|
||||
shows the ability's three definitions. When ISAC explicitly pairs that ability
|
||||
with one compatible ability, the picker also shows the partner's three
|
||||
definitions and labels shared entries. The relationship is bidirectional and
|
||||
does not apply to other abilities in the same specialization. Players still
|
||||
equip three chips in each ability panel. Skill Chips have no item-level control.
|
||||
The editor uses the shared quality profiles for direct rolls. It labels the four
|
||||
ability-scoped primary values `NOT YET DEFINED` and excludes them from current
|
||||
Agent and combat totals. Shared-chip primary values and 3-piece Talent outcomes
|
||||
target the ability represented by the panel. Secondary capacity is Standard
|
||||
zero, Specialized one, Superior two, and High-end two; each equipped chip uses
|
||||
distinct selections from the shared pool.
|
||||
|
||||
The focus tree groups nodes by row and uses segmented rank controls. Disabled
|
||||
ranks remain visible with their lock state. The builder permits a row after the
|
||||
user buys every available Rank 1 and Rank 2 in preceding rows and enforces the
|
||||
49-point limit. Cost telemetry lists Credits and components beside each rank.
|
||||
|
||||
Each ability panel exposes one compact activation control: Off, On Cooldown, or
|
||||
Custom Rate. Custom Rate adds uses per minute. Every enabled ability has its own
|
||||
target count. Skill result cards show the ability name, activation count,
|
||||
single-target DPS, total-target DPS, and a combined summary. They state that
|
||||
Skills can critically hit and multi-shot but cannot headshot.
|
||||
Player ability panels currently expose equipped definitions and deterministic
|
||||
item contributions. Planned simulation panels display `COMING SOON` instead of
|
||||
activation controls or Skill DPS. The future interface will offer Off, On
|
||||
Cooldown, or Custom Rate, with uses per minute and a target count for each
|
||||
enabled ability. Future Skill result cards will show activation count,
|
||||
single-target DPS, total-target DPS, and a combined summary. They will state
|
||||
that Skills can critically hit and multi-shot but cannot headshot.
|
||||
|
||||
Saved loadouts store focus, node ranks, nine Skill Chips, conditional modes,
|
||||
Version 5 loadouts store focus, node ranks, nine Skill Chips, conditional modes,
|
||||
encounter assumptions, and per-ability activation profiles. The builder accepts
|
||||
only the current complete structure. Invalid local drafts are discarded; invalid
|
||||
server records are removed through a backed-up database migration.
|
||||
only the current complete structure. Current player pages ignore the simulation
|
||||
fields. The builder discards version 4 local drafts; a backed-up database
|
||||
migration removes version 4 server records.
|
||||
|
||||
### Calculation revision controls
|
||||
|
||||
@@ -2581,6 +2715,10 @@ revision identity and before the manual stage and profile controls. Reuse the
|
||||
language, identifies the active or edited base revision, and previews changes to
|
||||
stages, weapon profiles, and Skill profiles.
|
||||
|
||||
ISAC keeps revision editing, calibration, assisted suggestions, publication, and
|
||||
the calculation AI assistant available for continued model authoring. The
|
||||
current Builder and shared Build pages do not consume their output.
|
||||
|
||||
Agents without `ai.calculation.generate` see the collapsed `PERMISSION REQUIRED`
|
||||
state. Agents with the permission may request and apply a draft, but the panel
|
||||
leaves every manual calculation control available. Applying a draft fills the
|
||||
@@ -2611,3 +2749,13 @@ asset and states that the change is permanent. For assigned icons, the
|
||||
confirmation names the owning entity type. ISAC clears that source icon
|
||||
assignment before it removes the shared file. General library uploads retain the
|
||||
`ICON` or `IMAGE` state.
|
||||
|
||||
In the Weapon Mod registry, operators manage shared artwork for all eight
|
||||
code-defined slots. The compact panel labels each slot `ICON REGISTERED` or
|
||||
`ICON REQUIRED`. Selecting a slot opens the standard icon upload control. Keep
|
||||
the slot name visible in ISAC after an operator uploads artwork.
|
||||
|
||||
On public weapon cards, a configured slot chip shows only its uploaded icon.
|
||||
Keep the slot name in screen-reader and hover text. Retain the visible `T1+`
|
||||
marker and blue information treatment for slots unlocked at Tier 1. When artwork
|
||||
is missing, show the slot name instead of an invented glyph.
|
||||
|
||||
@@ -26,8 +26,8 @@ Use these event boundaries:
|
||||
- **Healing:** Count each healing event. A heal-over-time effect counts each
|
||||
tick as a separate event.
|
||||
- **Extra Health gained:** Count every Extra Health grant, including a grant
|
||||
received while the Agent already has Extra Health. Use
|
||||
`extra_health_granted` for the grant action.
|
||||
received while the Agent already has Extra Health. Use `extra_health_granted`
|
||||
for the grant action.
|
||||
- **Extra Health state changed:** `extra_health_changed` is Agent-scoped. Emit
|
||||
it when the Agent receives a grant, when the remaining amount changes, and
|
||||
when it is depleted to zero. Every event requires the boolean
|
||||
@@ -35,8 +35,8 @@ Use these event boundaries:
|
||||
`values.currentExtraHealth`, which must agree with that status. This state
|
||||
event is distinct from the `extra_health_granted` grant action.
|
||||
- **Reloading:** Trigger on reload completion when the weapon had at least one
|
||||
round to load. A canceled reload does not count. A weapon that loads one
|
||||
round at a time produces one completed-reload event for each round.
|
||||
round to load. A canceled reload does not count. A weapon that loads one round
|
||||
at a time produces one completed-reload event for each round.
|
||||
- **Taking damage:** Require Health loss. A hit absorbed by Extra Health does
|
||||
not count as taking damage.
|
||||
|
||||
@@ -59,8 +59,8 @@ Inspiration and Crossfire use a binary deployed-Skill state:
|
||||
|
||||
- The benefit remains active while the Agent has at least one persistent
|
||||
deployed Skill object.
|
||||
- Destruction or timed expiration removes that object from the active count.
|
||||
The benefit ends when no qualifying objects remain.
|
||||
- Destruction or timed expiration removes that object from the active count. The
|
||||
benefit ends when no qualifying objects remain.
|
||||
- Instant-use Skills do not qualify.
|
||||
- Multiple deployed Skill objects do not multiply the benefit.
|
||||
|
||||
@@ -75,8 +75,8 @@ meters:
|
||||
|
||||
- Moving within 30 meters of an ally who is already downed qualifies.
|
||||
- Each qualifying presence refreshes the ten-second duration.
|
||||
- Leaving the radius or reviving the ally does not cancel the current
|
||||
ten-second duration.
|
||||
- Leaving the radius or reviving the ally does not cancel the current ten-second
|
||||
duration.
|
||||
- The wearer does not count as an ally.
|
||||
|
||||
## Emergency Retreat
|
||||
@@ -93,9 +93,9 @@ qualifies when the weapon had at least one round to load. This includes each
|
||||
round inserted by a weapon that reloads one round at a time. A canceled reload
|
||||
does not qualify.
|
||||
|
||||
Resolve the Extra Health amount from the Agent's displayed Firepower when
|
||||
Second Wind activates. Include all equipped-item and active-buff contributions
|
||||
in that Firepower value:
|
||||
Resolve the Extra Health amount from the Agent's displayed Firepower when Second
|
||||
Wind activates. Include all equipped-item and active-buff contributions in that
|
||||
Firepower value:
|
||||
|
||||
- Tier I grants Extra Health equal to 600% of displayed Firepower.
|
||||
- Tier II grants Extra Health equal to 840% of displayed Firepower.
|
||||
@@ -103,19 +103,39 @@ in that Firepower value:
|
||||
Second Wind adds its Extra Health to Extra Health from other sources. Its
|
||||
five-second duration and five-second cooldown start when it activates. Expiry
|
||||
removes only the Second Wind contribution. At the five-second boundary, the
|
||||
prior grant can expire as a qualifying reload completion grants a new amount.
|
||||
If Second Wind supplies the Agent's only Extra Health, the old amount expires
|
||||
and the new amount begins at that boundary.
|
||||
prior grant can expire as a qualifying reload completion grants a new amount. If
|
||||
Second Wind supplies the Agent's only Extra Health, the old amount expires and
|
||||
the new amount begins at that boundary.
|
||||
|
||||
## Nothing to Lose
|
||||
|
||||
Nothing to Lose uses a forward-looking deployment window:
|
||||
|
||||
- Each Health-damage instance makes its own 50% activation roll. Damage
|
||||
absorbed by Extra Health does not roll.
|
||||
- Each Health-damage instance makes its own 50% activation roll. Damage absorbed
|
||||
by Extra Health does not roll.
|
||||
- A successful activation applies to every Skill deployed during the five
|
||||
seconds after that damage instance. Several Skills can qualify during the
|
||||
same window.
|
||||
seconds after that damage instance. Several Skills can qualify during the same
|
||||
window.
|
||||
- Calculate each reduction from the Skill's total cooldown after equipment
|
||||
modifiers.
|
||||
- A Skill can receive no more than three reductions during one cooldown cycle.
|
||||
|
||||
## OS Protocol Talents
|
||||
|
||||
Happy Survival restores Health once per second while it has a qualifying nearby
|
||||
enemy count. Each restoration resolves 0.5% of maximum Health per enemy within
|
||||
10 meters, capped at 2.5% before OS Protocol upgrade scaling.
|
||||
|
||||
Explosive Charge requires three seconds of uninterrupted firing. Completion
|
||||
opens an eight-second window to deploy the next Skill. The window ends when the
|
||||
Agent deploys that Skill. Only the enhanced Skill's first damaging hit creates
|
||||
the five-meter explosion. The shared Talent cooldown rule starts its five-second
|
||||
cooldown when that explosion activates.
|
||||
|
||||
Heavy Armor and Pummeling Shield replace and refresh their own active Extra
|
||||
Health grant when the same Talent activates again. Extra Health grants from
|
||||
different sources remain separate.
|
||||
|
||||
Pummeling Shield counts confirmed weapon hits while the Agent possesses Extra
|
||||
Health. Losing all Extra Health resets the hit count. The full-Health condition
|
||||
checks Health immediately before the incoming hit resolves.
|
||||
|
||||
+294
-97
@@ -9,8 +9,8 @@ The team builds the project in vertical slices.
|
||||
data
|
||||
- Auth.js-compatible server authentication with Discord OAuth and credentials
|
||||
providers
|
||||
- Argon2id password hashing, server-side sessions, CSRF protection, and role
|
||||
checks
|
||||
- Argon2id password hashing, TOTP MFA with encrypted secrets, server-side
|
||||
sessions, CSRF protection, and role checks
|
||||
- Browser `localStorage` only for anonymous build drafts
|
||||
|
||||
## Product boundaries
|
||||
@@ -25,6 +25,13 @@ The team builds the project in vertical slices.
|
||||
Server code checks authorization for each mutation. UI visibility has no effect
|
||||
on server authorization.
|
||||
|
||||
Builder and shared Build pages currently expose only deterministic totals from
|
||||
the equipped items, selected rolls, fixed bonuses, inherited profiles, augments,
|
||||
and active set bonuses. Conditional Talent outcomes, weapon and Skill DPS,
|
||||
encounter assumptions, activation profiles, rotations, and simulation remain
|
||||
planned. Explicit `COMING SOON` panels identify those player-facing
|
||||
capabilities.
|
||||
|
||||
Weapon and gear range calculations must preserve decimal endpoints and follow
|
||||
the tested progression rules in
|
||||
[`STAT_LEVEL_SCALING.md`](STAT_LEVEL_SCALING.md). Leveling is linear rather than
|
||||
@@ -34,6 +41,43 @@ 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.
|
||||
|
||||
## Authentication and MFA
|
||||
|
||||
Sign-in never grants administrator authority. The first full administrator is
|
||||
created through an offline database operation after the target account has
|
||||
enrolled TOTP. The local command is:
|
||||
|
||||
```sh
|
||||
npm run admin:bootstrap -- <verified-email>
|
||||
```
|
||||
|
||||
Staging and production use the stopped-container Compose workflow documented in
|
||||
[`DEPLOYMENT.md`](DEPLOYMENT.md), not a host-side npm command. The bootstrap
|
||||
operation accepts exactly one active, unblocked account with a verified email,
|
||||
requires an active TOTP factor and an unused recovery code, requires that no
|
||||
active full administrator exists, assigns the canonical `role-admin`
|
||||
relationship, revokes the target's sessions, and records
|
||||
`role_management / administrator.bootstrapped`. Administrators must enroll TOTP
|
||||
before offline elevation.
|
||||
|
||||
`MFA_TOTP_ENCRYPTION_KEYS` is a required app-only secret. Its value contains one
|
||||
to three comma-separated, unique `key-id:canonical-base64url-32-byte-key`
|
||||
entries. The first entry is the active encryption key for new factors; later
|
||||
entries retain decryption access to older factors during rotation. There is no
|
||||
generated-key fallback. Rotate by prepending a newly generated 32-byte key with
|
||||
a new ID, deploying it with the old entries retained, and verifying existing
|
||||
TOTP authentication. A successful verification rewraps that factor under the
|
||||
first key. Do not remove an old entry until every factor encrypted with it has
|
||||
been rewrapped and verified. Keep key material in the secret manager and never
|
||||
place it in source, logs, audit snapshots, or documentation.
|
||||
|
||||
TOTP enrollment issues ten one-time recovery codes. The interface displays them
|
||||
only once; the database stores only domain-separated hashes. Save the codes in
|
||||
an offline password manager or another protected offline record. Each code is
|
||||
consumed once. Regenerating codes immediately revokes all unused previous codes
|
||||
and displays a new set once. Disabling TOTP revokes its recovery codes, and a
|
||||
full administrator cannot disable TOTP.
|
||||
|
||||
## Database runtime
|
||||
|
||||
The application uses Turso Database in-process through its native Node package.
|
||||
@@ -174,9 +218,45 @@ stores only its domain-separated hash. Repeated anonymous activity requires a
|
||||
Turnstile result. Guide reports reference the exact published revision so a
|
||||
report against an older revision cannot hide newer work.
|
||||
|
||||
The Builder stores loadout data only. It has no summary or Guide body. An owned
|
||||
Build page links to `/guides/new?build=<id>`, where the server verifies
|
||||
ownership and initializes a removable Build block in a new private Guide.
|
||||
The Builder stores loadout data and displays deterministic equipped-item totals.
|
||||
It has no Guide body. Shared Build pages use the same player-facing calculation
|
||||
boundary. An owned Build page links to `/guides/new?build=<id>`, where the
|
||||
server verifies ownership and initializes a removable Build block in a new
|
||||
private Guide.
|
||||
|
||||
### Discord-compatible Build sharing experiment
|
||||
|
||||
Persisted public and unlisted Builds expose absolute Open Graph metadata for
|
||||
their canonical `/builds/[slug]` URL. Their `og:image` and Twitter card metadata
|
||||
reference the same-origin 1200 × 630 PNG at `/builds/[slug]/preview.png` so
|
||||
Discord and other link unfurlers can render a Build summary. Private Builds set
|
||||
`noindex,nofollow`, expose no crawler image, and provide no usable sharing
|
||||
control.
|
||||
|
||||
The preview endpoint derives its image from canonical Build facts and the
|
||||
current known-stat summary in the shared Build projection. It includes the Build
|
||||
title, author, visibility, direct URL, equipped weapon names, core totals, and
|
||||
up to three supporting Attributes. The renderer performs no external fetches. It
|
||||
normalizes and bounds every text field by grapheme count, escapes text before
|
||||
inserting it into XML, builds a text-only SVG, and rasterizes that SVG to PNG
|
||||
with Sharp. Production installs DejaVu Sans from the Debian package and the
|
||||
renderer names that fixed family explicitly.
|
||||
|
||||
The endpoint hashes a versioned serialization of the image input for its bounded
|
||||
in-process render cache and derives the HTTP ETag from the resulting PNG bytes.
|
||||
A bounded request queue limits concurrent database projections and Sharp work.
|
||||
Responses require cache revalidation so a visibility change to Private blocks
|
||||
the next request instead of leaving a stale response window. Matching
|
||||
conditional requests receive `304 Not Modified`. Private, missing, and
|
||||
inaccessible Builds receive no preview. Unlisted images send the
|
||||
`X-Robots-Tag: noindex` response header.
|
||||
|
||||
For eligible Builds, the Build Uplink invokes the native Web Share API as the
|
||||
primary action when the browser supports it. An explicit Copy Link action
|
||||
remains available for every public or unlisted persisted Build. This experiment
|
||||
does not install a Discord bot, post to Discord, extend Discord OAuth scopes, or
|
||||
store Discord tokens or webhooks. Discord receives only a link that an Agent
|
||||
chooses to share and may fetch the public metadata and same-origin preview.
|
||||
|
||||
## Public data corrections and discussion
|
||||
|
||||
@@ -201,10 +281,9 @@ does not become part of the canonical action replay.
|
||||
|
||||
# Administrator bootstrap
|
||||
|
||||
The authentication service promotes the first authenticated user to `admin` if
|
||||
the database does not contain an administrator. Promotion uses one conditional
|
||||
database update so concurrent first sign-ins cannot both become administrators.
|
||||
Once an administrator exists, later sign-ins never alter roles.
|
||||
The first full administrator is created only through the offline flow documented
|
||||
in [Authentication and MFA](#authentication-and-mfa). Sign-in never grants
|
||||
administrator authority or changes an account's roles.
|
||||
|
||||
# Database backup and restore
|
||||
|
||||
@@ -388,11 +467,13 @@ reference.
|
||||
|
||||
# OS Protocol progression
|
||||
|
||||
OS Protocols use dedicated normalized tables because their talent progression
|
||||
has six calculated states (base plus five upgrades) rather than the shared
|
||||
two-tier talent model. Each protocol owns one `os_protocol_primary_effects`
|
||||
record and one `os_protocol_talents` record. Each protocol Talent owns its
|
||||
mechanic program through the shared conditional-effect tables.
|
||||
OS Protocols use dedicated normalized tables because an assigned Talent has six
|
||||
calculated states: its base value plus five upgrades. Each protocol owns one
|
||||
`os_protocol_primary_effects` record and may own one `os_protocol_talents`
|
||||
record. The optional Talent owns its mechanic program through the shared
|
||||
conditional-effect tables. ISAC keeps the Talent workspace collapsed until an
|
||||
operator assigns or opens it, and removing an existing Talent requires explicit
|
||||
confirmation.
|
||||
|
||||
Dedicated profile tables normalize the three secondary attributes. One
|
||||
`os_protocol_secondary_profiles` record exists for each configured quality +
|
||||
@@ -417,6 +498,15 @@ compatible base weapon categories through `weapon_mod_compatible_categories`;
|
||||
eligibility requires both a category match and the slot to exist on the equipped
|
||||
weapon at its selected tier.
|
||||
|
||||
The eight code-defined slot types have stable UUIDs and one shared icon each.
|
||||
Operators manage the artwork in the Weapon Mod registry through the game-data
|
||||
icon pipeline. Each upload or removal appends a
|
||||
`game_data/weapon_mod_slot_icon.updated` audit event, and contributors may send
|
||||
the same action to Data Oversight. Public weapon cards use icon-only slot chips
|
||||
when artwork exists, with the slot name available to screen readers and on
|
||||
hover. Cards show the name when artwork is missing and retain the visible `T1+`
|
||||
marker for slots unlocked at Tier 1.
|
||||
|
||||
Exotic Weapon Mods instead reference one `exotic_weapon_id`. They do not carry
|
||||
regular category compatibility. The builder treats them as permanent equipment
|
||||
and lets the user change only their level. They do not use quality profiles,
|
||||
@@ -426,16 +516,21 @@ values in `weapon_mod_exotic_attributes`; intermediate values use linear
|
||||
interpolation.
|
||||
|
||||
All regular Weapon Mods permit Firepower, Toughness, or Engineering as a
|
||||
builder-time primary choice. `weapon_mod_quality_values` stores the shared
|
||||
level-1 and level-20 primary roll endpoints for each quality; primary values use
|
||||
linear interpolation from levels 1–20.
|
||||
builder-time primary choice. `weapon_mod_quality_values` stores each
|
||||
definition's level-1 and level-20 primary roll endpoints for each quality;
|
||||
primary values use linear interpolation from levels 1–20.
|
||||
|
||||
`weapon_mod_secondary_options` is the shared pool from which a build selects two
|
||||
distinct secondary attributes. Every option owns a minimum and maximum at each
|
||||
quality through `weapon_mod_secondary_quality_values`; secondary availability is
|
||||
not tied to a numbered slot in canonical data. Mod Effects are triggerless, hold
|
||||
one or two attribute references, and store fixed per-quality values in
|
||||
`weapon_mod_effect_quality_values`.
|
||||
`weapon_mod_secondary_options` stores one global secondary Attribute pool for
|
||||
all regular Weapon Mods. Each Attribute has Level 1 and Level 20 minimum and
|
||||
maximum ranges for Specialized, Superior, and High-end quality through
|
||||
`weapon_mod_secondary_quality_values`; values at intermediate levels use linear
|
||||
interpolation. Secondary capacity is fixed by quality: Standard has zero,
|
||||
Specialized has one, Superior has two, and High-end has two. A build selects
|
||||
distinct Attributes from the pool up to that capacity. Individual Weapon Mod
|
||||
records do not own secondary options or ranges. Pool updates append a
|
||||
`game_data/weapon_mod_secondary_pool.updated` audit event. Mod Effects remain
|
||||
triggerless, hold one or two Attribute references, and store fixed per-quality
|
||||
values in `weapon_mod_effect_quality_values`.
|
||||
|
||||
### Gear Mods and tier slot capacity
|
||||
|
||||
@@ -445,42 +540,86 @@ Specialized, Superior, and High-end quality at Levels 1 and 20. Values between
|
||||
those endpoints use linear interpolation. A build instance selects one primary
|
||||
attribute from Firepower, Toughness, Engineering, Health, or Armor.
|
||||
|
||||
The two secondary slots draw distinct attributes from one shared option pool.
|
||||
Each available secondary attribute stores its own minimum and maximum for all
|
||||
four qualities. Gear tier determines capacity without per-item configuration: T0
|
||||
gear has one Gear Mod slot; T1 and T2 gear have two.
|
||||
One global Gear Mod secondary Attribute pool serves every Gear Mod definition.
|
||||
Each Attribute has Level 1 and Level 20 minimum and maximum ranges for
|
||||
Specialized, Superior, and High-end quality through
|
||||
`gear_mod_secondary_quality_values`; values at intermediate levels use linear
|
||||
interpolation. Secondary capacity is fixed by quality: Standard has zero,
|
||||
Specialized has one, Superior has two, and High-end has two. A build selects
|
||||
distinct Attributes from the pool up to that capacity. Individual Gear Mod
|
||||
records do not own secondary options or ranges. Gear tier still determines mod
|
||||
slot capacity without per-item configuration: T0 gear has one Gear Mod slot; T1
|
||||
and T2 gear have two.
|
||||
|
||||
The `gear_mods`, `gear_mod_quality_values`, `gear_mod_secondary_options`, and
|
||||
`gear_mod_secondary_quality_values` tables store canonical data. Successful
|
||||
mutations use `gear_mod.created`, `gear_mod.updated`, `gear_mod.archived`, and
|
||||
`gear_mod.unarchived` audit actions. Contributor submissions use the `gearmods`
|
||||
proposal module and remain non-canonical until approved by Oversight.
|
||||
The `gear_mods` and `gear_mod_quality_values` tables store individual definition
|
||||
data. `gear_mod_secondary_options` and `gear_mod_secondary_quality_values` store
|
||||
the family-wide pool. Pool updates append a
|
||||
`game_data/gear_mod_secondary_pool.updated` audit event. Individual definition
|
||||
mutations retain the `gear_mod.created`, `gear_mod.updated`,
|
||||
`gear_mod.archived`, and `gear_mod.unarchived` audit actions. Contributor
|
||||
submissions use the `gearmods` proposal module and remain non-canonical until
|
||||
approved by Oversight.
|
||||
|
||||
# Skill Chip quality scaling
|
||||
|
||||
Skill Chip sets support Superior and High-end chip instances. Each set owns one
|
||||
2-piece bonus, distinct primary and secondary stat-option pools, and one 3-piece
|
||||
Talent with a typed conditional rule. Primary and secondary options reference
|
||||
active Attributes that define Skill Chip minimum and maximum roll values. A
|
||||
configured chip rolls one primary and two distinct secondary stats from those
|
||||
pools.
|
||||
Each Skill Chip definition belongs to one specialization ability. Each set owns
|
||||
one 2-piece bonus and may own one 3-piece Talent with a typed conditional rule.
|
||||
ISAC keeps the complete Talent workspace, including its description and mechanic
|
||||
program, inside one collapsible optional section.
|
||||
|
||||
Each active Skill Chip definition belongs to one active specialization ability
|
||||
through `skill_chip_sets.specialization_ability_id`. An ability has capacity for
|
||||
three active definitions. The save and restore actions enforce that capacity,
|
||||
and the schema requires the foreign key for each row. A specialization focus is
|
||||
All definitions use one fixed primary profile. The profile contains Skill
|
||||
Duration, Skill Radius, Skill Cooldown Recovery, and Skill Intensity, with
|
||||
direct minimum and maximum values for Standard, Specialized, Superior, and
|
||||
High-end. Primary values apply to the assigned ability rather than the Agent.
|
||||
Builder and shared Build pages label them `NOT YET DEFINED` and exclude them
|
||||
from current Agent and combat totals.
|
||||
|
||||
All definitions also use one shared secondary Attribute pool. Each pool entry
|
||||
stores direct minimum and maximum values for Specialized, Superior, and
|
||||
High-end. Standard chips have no secondary slot, Specialized chips have one, and
|
||||
Superior and High-end chips have two. A configured chip selects distinct
|
||||
secondary Attributes up to its quality's capacity. Skill Chips have no item
|
||||
level, so neither profile uses level interpolation.
|
||||
|
||||
Active definitions reference an active specialization ability through
|
||||
`skill_chip_sets.specialization_ability_id`. An ability has capacity for three
|
||||
active definitions. The save and restore actions enforce that capacity, and the
|
||||
schema requires the foreign key for each row. A specialization focus is
|
||||
builder-ready only when each of its three assigned abilities has all three
|
||||
active Skill Chip definitions. Skill Chip mutations refresh readiness for
|
||||
focuses that reference both the previous and resulting ability.
|
||||
|
||||
Both set-bonus progression systems use the number of equipped High-end chips as
|
||||
the interpolation step. For `h` High-end chips, where `h` is zero through three,
|
||||
a configured minimum and maximum resolve as
|
||||
`minimum + (maximum − minimum) × h / 3`. Zero High-end chips therefore produce
|
||||
the minimum, while three produce the maximum. The editor stores endpoints and
|
||||
previews all four states, preventing duplicated tier data from diverging. The
|
||||
`game_data` audit stream captures Skill Chip mutations, owned relationships, and
|
||||
safe icon metadata.
|
||||
`specialization_ability_skill_chip_links` may pair two active abilities from the
|
||||
same specialization. Operators create each pair explicitly; the application does
|
||||
not infer compatibility from ability names, focus membership, or shared
|
||||
specialization. The pair is bidirectional, and an ability may belong to only one
|
||||
pair. This permits compatible abilities such as two Tactical Link variants to
|
||||
share definitions without making unrelated pairs such as two Smart Cover
|
||||
variants compatible. Archiving either ability removes the pair; restoring an
|
||||
ability does not recreate it.
|
||||
|
||||
A linked ability can equip its own three definitions or its partner's three
|
||||
definitions, so each of its three Builder positions offers at most six choices.
|
||||
The loadout still equips three Skill Chips per ability and nine per focus.
|
||||
Ability-scoped primary values and 3-piece Talent outcomes bind to the ability
|
||||
that owns the equipped position, not the definition's original owner. Two-piece
|
||||
and 3-piece thresholds remain local to one equipped ability and never combine
|
||||
matching definitions across ability panels. ISAC audit snapshots and game-data
|
||||
transfers include the compatibility pair.
|
||||
|
||||
Every equipped quality counts toward the two-piece threshold. After the loadout
|
||||
meets that threshold, only equipped High-end chips increase the interpolation
|
||||
step. For `h` High-end chips, where `h` is zero through three, a configured
|
||||
minimum and maximum resolve as `minimum + (maximum − minimum) × h / 3`. Zero
|
||||
High-end chips resolve to the minimum, while three resolve to the maximum. The
|
||||
editor stores endpoints and previews all four states.
|
||||
|
||||
ISAC presents the fixed primary profile and secondary pool as two shared
|
||||
registry/editor cards above the definition registry. Successful profile saves
|
||||
append `game_data/skill_chip_primary_profile.updated` and
|
||||
`game_data/skill_chip_secondary_pool.updated`, respectively. Definition
|
||||
mutations continue to capture their owned bonus, optional Talent, ability
|
||||
relationship, and safe icon metadata in the `game_data` audit stream.
|
||||
|
||||
# Mechanic programs and loadout state
|
||||
|
||||
@@ -490,19 +629,23 @@ Talent, OS Protocol Talent, Skill Chip Talent, or Specialization tree rank. The
|
||||
`source_type` and `source_id` pair identifies that owner. No standalone Trigger
|
||||
record or shared Trigger relationship participates in execution.
|
||||
|
||||
The builder supplies normalized timeline events to each equipped mechanic.
|
||||
`movement_snapshot.values.state` is `immobile`, `moving`, or `sprinting`; the
|
||||
mechanic's moving and sprinting scenario controls select that state, with
|
||||
sprinting taking precedence. Enabled Skill activation profiles emit
|
||||
The mechanic runtime accepts normalized timeline events for each equipped
|
||||
mechanic. Current player pages do not construct this timeline or invoke the
|
||||
runtime. `movement_snapshot.values.state` is `immobile`, `moving`, or
|
||||
`sprinting`; the mechanic's moving and sprinting scenario controls select that
|
||||
state, with sprinting taking precedence. Enabled Skill activation profiles emit
|
||||
`skill_deployed` events on the same cadence as Skill calculations and identify
|
||||
the deployed ability. An `on_event` Skill modifier applies to that activation
|
||||
until the ability's next deployment or the encounter ends.
|
||||
|
||||
`skill_deployed.values.abilityId` and `skill_hit.values.abilityId` identify the
|
||||
same ability for mechanics that bind an effect to the next deployed Skill.
|
||||
|
||||
Each source supplies a fixed value-variant contract:
|
||||
|
||||
- Standard Talents require Tier I and Tier II values. Exotic Talents require
|
||||
Tier I and may omit Tier II until contributors document its game values.
|
||||
Builder calculations use Tier I while an equipped exotic Talent has no Tier II
|
||||
Runtime calculations use Tier I while an equipped exotic Talent has no Tier II
|
||||
mechanic variant.
|
||||
- OS Protocol Talents store a base value. An outcome may use an expression that
|
||||
reads `equipment.protocolTalentUpgrades`, from zero through five, so selected
|
||||
@@ -533,7 +676,23 @@ state, source, movement, and nearby counts travel as typed event values. The
|
||||
event registry publishes authoritative field metadata where branches need a
|
||||
bounded contract. For those events, the mechanic editor derives its event-field
|
||||
controls from that metadata. For example, `weapon_hit.values.headshot` is a
|
||||
boolean that distinguishes headshots from body shots.
|
||||
boolean that distinguishes headshots from body shots. `enemy_killed` and
|
||||
`assist` may carry `values.assignedAbilityActive`; a Skill Chip Talent uses that
|
||||
boolean to restrict a kill or assist trigger to its assigned ability's active
|
||||
period. `skill_deployed` and `signature_ability_deployed` may carry
|
||||
`values.assignedAbilityDurationSeconds` to start assigned-ability state. The
|
||||
runtime expires that state before it evaluates another event at the same
|
||||
timestamp.
|
||||
|
||||
`weapon_hit.values.aiming` records whether the Agent was aiming down sights when
|
||||
the hit resolved. A producer omits the field when it lacks authoritative aiming
|
||||
state; the runtime does not replace missing evidence with a scenario assumption.
|
||||
Predicates compare this field explicitly with `true` or `false`; negation and
|
||||
inequality cannot turn missing evidence into a match. A `weapon_hit` or
|
||||
`skill_hit` event may include an event-target-centered area sample. The bounded
|
||||
sample lists up to 256 valid ally and enemy entity IDs with their distances from
|
||||
the hit target and includes that target as an enemy at zero meters. Duplicate
|
||||
IDs, negative distances, or a missing center target invalidate the evaluation.
|
||||
|
||||
`extra_health_changed` is Agent-scoped. Normalized timeline producers emit it
|
||||
when the Agent receives an Extra Health grant, when the remaining amount
|
||||
@@ -568,7 +727,10 @@ The program graph uses three child tables:
|
||||
durations, intervals, or derived values. Mutations can set, add, consume,
|
||||
clear, derive, or transfer state. A mutation may bind a target-scoped pool to
|
||||
the current event target, event actor, or another typed entity selector. Each
|
||||
target owns an independent value and expiry timer.
|
||||
target owns an independent value and expiry timer. A clause may require active
|
||||
assigned-ability state and carry `procChancePercent`. The runtime evaluates
|
||||
that chance after predicates and cooldown checks, before reducers, mutations,
|
||||
and outcomes.
|
||||
- `conditional_effect_outcomes` applies Attribute modifiers, direct damage,
|
||||
restoration, resource costs, or markers. Each outcome names its target scope,
|
||||
value basis, optional resource or Attribute, combat timing, optional state
|
||||
@@ -577,15 +739,37 @@ The program graph uses three child tables:
|
||||
required by the owning source. The common value models are static values, live
|
||||
Attribute values, percentage-normalized Attribute values, event-field values,
|
||||
reducer output, source-defined scenario values, and OS Protocol upgrade
|
||||
scaling. Restoration outcomes may deliver their total immediately or
|
||||
scaling. An outcome may carry `probabilityPercent`; each eligible outcome
|
||||
rolls separately. Restoration outcomes may deliver their total immediately or
|
||||
continuously over an explicit duration.
|
||||
|
||||
Target-scoped direct damage may use `event_target_area` targeting for an
|
||||
explosion around its event target. This targeting selects valid enemies and
|
||||
stores a positive radius in meters. It is valid only for `on_event` or
|
||||
`after_event` outcomes. The legacy `within_radius` shape remains descriptive
|
||||
targeting metadata and does not select runtime entities.
|
||||
|
||||
The generic runtime in `src/lib/calculations/mechanic.ts` processes combat
|
||||
events in timestamp order. Predicates decide whether an event enters a clause.
|
||||
Reducers convert event streams into activations or derived values. The runtime
|
||||
applies state mutations, enforces pool bounds and expiry, emits outcomes in
|
||||
phase order, and records telemetry for each activation.
|
||||
|
||||
When `event_target_area` damage activates, the runtime requires the triggering
|
||||
event's authoritative area sample. It includes valid enemies whose distance is
|
||||
less than or equal to the configured radius, sorts them by entity ID, and emits
|
||||
one equal damage outcome for each entity. The outcome chance resolves once
|
||||
before fan-out. A missing sample invalidates the evaluation instead of producing
|
||||
single-target damage or treating the area as empty.
|
||||
|
||||
Custom evaluation uses a Mulberry32 stream seeded by `randomSeed`; an omitted
|
||||
seed resolves from the stable mechanic program ID. The returned evaluation
|
||||
reports the seed. Maximum mode resolves every nonzero clause or outcome chance
|
||||
as successful. `extend_duration` changes only an active assigned ability's
|
||||
remaining duration. `start_cooldown_on_max` writes an expiring Agent cooldown
|
||||
pool only when an earlier mutation in the same clause transitions a capped stack
|
||||
pool to its maximum.
|
||||
|
||||
Each write to an expiring pool refreshes its timer. When a pool expires,
|
||||
`on_exit` outcomes evaluate stack thresholds and per-stack expressions against
|
||||
the final value before reset. The runtime records the bound target on emitted
|
||||
@@ -603,11 +787,13 @@ substitute zero.
|
||||
The read-only `combat.currentHealthPercent` reference divides resolved current
|
||||
Health by resolved maximum Health and returns a value from 0 through 100. The
|
||||
runtime derives it when a predicate is evaluated instead of storing a second
|
||||
Health value that could become stale. Current timing observes deterministic
|
||||
resource changes from earlier branches at the same event. Health-band mechanics
|
||||
use strict upper bounds and inclusive lower bounds, so bands do not overlap and
|
||||
an exact threshold enters the next weaker band. Maximum-potential evaluation
|
||||
applies the same predicates before emitting passive outcomes.
|
||||
Health value that could become stale. Pre-event timing reads the combat snapshot
|
||||
before mechanic branches process the event. Current timing observes
|
||||
deterministic resource changes from earlier branches at the same event.
|
||||
Health-band mechanics use strict upper bounds and inclusive lower bounds, so
|
||||
bands do not overlap and an exact threshold enters the next weaker band.
|
||||
Maximum-potential evaluation applies the same predicates before emitting passive
|
||||
outcomes.
|
||||
|
||||
Continuous restoration resolves the outcome's total when the branch activates,
|
||||
then delivers that total at a constant rate over the configured duration.
|
||||
@@ -622,10 +808,11 @@ uses `requires_assumptions`, identifies the missing input, and stays out of the
|
||||
calculation until an operator or builder scenario supplies it.
|
||||
|
||||
The runtime derives a read-only pseudo-language listing from the typed program.
|
||||
ISAC and the builder use that listing to explain events, predicates, reducers,
|
||||
state changes, phases, and formulas. No route accepts the generated text as
|
||||
input, and no server process executes it. Operators edit structured fields; the
|
||||
formatter regenerates the listing after each change.
|
||||
ISAC uses that listing to explain events, predicates, reducers, state changes,
|
||||
phases, and formulas. Planned player calculations can reuse it when those pages
|
||||
consume mechanic outcomes. No route accepts the generated text as input, and no
|
||||
server process executes it. Operators edit structured fields; the formatter
|
||||
regenerates the listing after each change.
|
||||
|
||||
## AI-assisted mechanic drafting
|
||||
|
||||
@@ -732,7 +919,8 @@ clarification requests. It does not grant revision mutation or publication.
|
||||
Applying a result changes the open revision form in the client. The operator
|
||||
must review and save that draft through the existing revision action. The
|
||||
separate `calculation.publish` permission and activation confirmation remain
|
||||
mandatory before the saved draft affects loadout results.
|
||||
mandatory before the saved draft becomes the active authored model. Current
|
||||
player pages do not consume that model.
|
||||
|
||||
Successful calculation-generation requests append sanitized events under
|
||||
`ai_assistance`. These events use calculation-specific actions, identify the
|
||||
@@ -741,23 +929,24 @@ token-usage, result, and secret-exclusion rules as mechanic-assistance events.
|
||||
Applying a draft creates no server event. Existing `calculation_configuration`
|
||||
events remain the evidence for revision saves and activation.
|
||||
|
||||
Source authors define the builder controls required by their mechanic as typed
|
||||
toggle, slider, or numeric scenario inputs. Each input records its label,
|
||||
guidance, bounds when applicable, default value, and best-case value. These
|
||||
controls appear only in advanced scenario configuration. Maximum mode supplies
|
||||
best-case values. Custom mode uses the values saved with the loadout. A mechanic
|
||||
with an undefined required assumption remains excluded and reports
|
||||
`REQUIRES_ASSUMPTIONS`.
|
||||
Source authors define planned player scenario controls as typed toggle, slider,
|
||||
or numeric inputs. Each input records its label, guidance, bounds when
|
||||
applicable, default value, and best-case value. ISAC retains these definitions
|
||||
for continued mechanic authoring. Current Builder and shared Build pages do not
|
||||
render or evaluate them. Their calculation panels display `COMING SOON` instead.
|
||||
|
||||
Loadout schema version 4 stores one focus, selected tree ranks, nine Skill
|
||||
Chips, a unified conditional-effect state map, encounter assumptions,
|
||||
per-ability activation controls, and `calculationMode`. Runtime readers accept
|
||||
the exact version 4 structure. A backed-up prototype migration removes drafts,
|
||||
saved builds, and calibration snapshots that use incompatible payloads.
|
||||
Loadout schema version 6 stores one focus, selected tree ranks, nine Skill
|
||||
Chips, independent T1/T2 choices for each equipped gear and weapon Talent, a
|
||||
unified conditional-effect state map, encounter assumptions, per-ability
|
||||
activation controls, and `calculationMode`. Runtime readers accept the exact
|
||||
version 6 structure. A backed-up migration translated version 5 equipment
|
||||
Talents from their previous equipment-tier-derived behavior. The schema retains
|
||||
its simulation fields, but the current player UI ignores them. Version 4 remains
|
||||
obsolete prototype data.
|
||||
|
||||
## Calculation modes
|
||||
## Planned player calculation modes
|
||||
|
||||
The builder offers `maximum` and `custom` modes.
|
||||
Loadout v6 defines `maximum` and `custom` modes for the planned simulation UI:
|
||||
|
||||
- Maximum mode supplies each scenario input's best-case value, selects peak
|
||||
conditional-effect state, and runs selected abilities on cooldown against
|
||||
@@ -767,9 +956,10 @@ The builder offers `maximum` and `custom` modes.
|
||||
rates, target counts, and other supported assumptions. A missing required
|
||||
input produces `REQUIRES_ASSUMPTIONS` rather than a fabricated value.
|
||||
|
||||
Saved and shared loadouts preserve the selected mode. Calculation results label
|
||||
the mode so readers can distinguish a best-case ceiling from a scenario-based
|
||||
estimate.
|
||||
Saved and shared loadouts preserve the selected mode as compatibility data. The
|
||||
current Builder and shared Build UI does not expose a mode selector, read
|
||||
encounter assumptions, or execute either mode. Explicit `COMING SOON` panels
|
||||
reserve the player-facing space for these calculations.
|
||||
|
||||
## Specialization ability attacks
|
||||
|
||||
@@ -795,6 +985,12 @@ holds the first position, and the firing-cycle stage holds the last.
|
||||
`attribute_calculation_profiles` maps an Attribute to a weapon stage with its
|
||||
weight, optional caps, curve, and weapon-category scope.
|
||||
|
||||
ISAC keeps combat revision editing, publication, calibration, assisted
|
||||
suggestions, and the calculation AI assistant available so operators can
|
||||
continue authoring the model. Builder and shared Build pages do not load the
|
||||
active revision or consume revision, calibration, assistant, mechanic-runtime,
|
||||
or combat-engine output at the current player-facing boundary.
|
||||
|
||||
The shared combat engine consumes weapon facts, accumulated Attributes,
|
||||
calculation profiles, and mechanic outcomes. It produces four hit values, burst
|
||||
DPS, reload-aware sustained DPS, encounter DPS, and stack telemetry. Custom mode
|
||||
@@ -803,11 +999,11 @@ configured burst size, pauses between bursts, and encounter duration. It
|
||||
resolves the active Attribute state at each landed shot before calculating that
|
||||
shot's expected damage.
|
||||
|
||||
The builder scales a weapon definition's level-1 damage range to the equipped
|
||||
item level, then the agent selects one damage value inside that valid range.
|
||||
Combat receives `equipped_weapon.damage` as its base damage and consumes that
|
||||
number once. The combat pipeline has no `level_scaled_damage` value and must not
|
||||
apply item-level scaling a second time.
|
||||
The deterministic equipment path scales a weapon definition's level-1 damage
|
||||
range to the equipped item level, then the agent selects one damage value inside
|
||||
that valid range. A combat invocation receives `equipped_weapon.damage` as its
|
||||
base damage and consumes that number once. The combat pipeline has no
|
||||
`level_scaled_damage` value and must not apply item-level scaling a second time.
|
||||
|
||||
The generated calculation listing names each typed pipeline stage and its bound
|
||||
Attributes. The listing explains the active revision with statements such as
|
||||
@@ -829,8 +1025,8 @@ the builder supports a combined weapon-rotation model.
|
||||
|
||||
The engine returns `INSUFFICIENT_DATA` with missing requirement keys when a
|
||||
calculation lacks required inputs or an Attribute binding. Callers must render
|
||||
that state instead of a partial result. Saved and shared builds load the current
|
||||
active revision for each request.
|
||||
that state instead of a partial result. Current player routes do not call the
|
||||
engine; their planned calculation surfaces display `COMING SOON`.
|
||||
|
||||
`calculation_calibration_cases` stores a versioned loadout snapshot, observed
|
||||
hit values, timing values, optional encounter DPS, and the model prediction.
|
||||
@@ -872,11 +1068,12 @@ does not scale.
|
||||
|
||||
# Game-data icon storage
|
||||
|
||||
Talents, brand sets, OS Protocols, and Skill Chips reference uploaded artwork
|
||||
through the polymorphic `game_data_icons` metadata table. The shared server
|
||||
pipeline accepts transparent SVG, PNG, WebP, and AVIF sources, rejects active
|
||||
SVG content and external references, and uses Sharp’s native worker pool to
|
||||
rasterize and normalize each accepted icon to a transparent 256 × 256 WebP.
|
||||
Talents, brand sets, OS Protocols, Skill Chips, Specializations, and shared
|
||||
Weapon Mod slots reference uploaded artwork through the polymorphic
|
||||
`game_data_icons` metadata table. The shared server pipeline accepts transparent
|
||||
SVG, PNG, WebP, and AVIF sources, rejects active SVG content and external
|
||||
references, and uses Sharp’s native worker pool to rasterize and normalize each
|
||||
accepted icon to a transparent 256 × 256 WebP.
|
||||
|
||||
Normalized files live beneath the ignored `data/game-icons/` directory. The
|
||||
validated `/icons/game-data/[type]/[id]` endpoint serves them with immutable
|
||||
|
||||
Generated
+25
@@ -21,6 +21,7 @@
|
||||
"argon2": "^0.45.1",
|
||||
"nodemailer": "^9.0.3",
|
||||
"openai": "^6.49.0",
|
||||
"otpauth": "9.5.1",
|
||||
"sharp": "^0.35.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -706,6 +707,18 @@
|
||||
"@emnapi/runtime": "^1.7.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
||||
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.139.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
|
||||
@@ -3486,6 +3499,18 @@
|
||||
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/otpauth": {
|
||||
"version": "9.5.1",
|
||||
"resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.5.1.tgz",
|
||||
"integrity": "sha512-fJmDAHc8wImfqqqOXIlBvT1dEKrZK0Cmb2VEgScpNTolCz0PHh6ExUZGv4sLtOsWNaHCQlD+rRqaPgnoxFoZjQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "2.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/hectorm/otpauth?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"db:migrate:production": "infisical run --env=prod --path=/app --path=/shared/scheduler -- node scripts/db-migrate.mjs",
|
||||
"db:seed-users": "infisical run --env=dev --path=/app -- node scripts/seed-initial-users.mjs",
|
||||
"db:seed-users:reset": "infisical run --env=dev --path=/app -- node scripts/seed-initial-users.mjs --reset-passwords",
|
||||
"admin:bootstrap": "infisical run --env=dev --path=/app -- node scripts/admin-bootstrap.mjs",
|
||||
"db:backup": "infisical run --env=dev --path=/app -- node scripts/db-backup.mjs",
|
||||
"db:backup:staging": "infisical run --env=staging --path=/app -- node scripts/db-backup.mjs",
|
||||
"db:backup:production": "infisical run --env=prod --path=/app -- node scripts/db-backup.mjs",
|
||||
@@ -79,6 +80,7 @@
|
||||
"argon2": "^0.45.1",
|
||||
"nodemailer": "^9.0.3",
|
||||
"openai": "^6.49.0",
|
||||
"otpauth": "9.5.1",
|
||||
"sharp": "^0.35.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { connect } from '@tursodatabase/database';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { resolve } from 'node:path';
|
||||
import process from 'node:process';
|
||||
|
||||
const usage = 'Usage: npm run admin:bootstrap -- <verified-email>';
|
||||
const suppliedEmails = process.argv.slice(2);
|
||||
if (suppliedEmails.length !== 1) throw new Error(usage);
|
||||
|
||||
const email = suppliedEmails[0]
|
||||
.normalize('NFKC')
|
||||
.trim()
|
||||
.toLocaleLowerCase('en-US');
|
||||
if (
|
||||
email.length < 3 ||
|
||||
email.length > 320 ||
|
||||
!/^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(email) ||
|
||||
/[\r\n]/u.test(email)
|
||||
) {
|
||||
throw new Error('Provide one valid verified email address.');
|
||||
}
|
||||
|
||||
const databasePath = resolve(
|
||||
process.env.TDR_DATABASE_PATH || './data/shd-build-development.db',
|
||||
);
|
||||
const database = await connect(databasePath);
|
||||
let transactionStarted = false;
|
||||
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
await database.exec('BEGIN IMMEDIATE');
|
||||
transactionStarted = true;
|
||||
|
||||
const matchingAccounts = await (
|
||||
await database.prepare(`
|
||||
SELECT users.id
|
||||
FROM users
|
||||
JOIN user_emails ON user_emails.user_id=users.id
|
||||
LEFT JOIN user_status ON user_status.user_id=users.id
|
||||
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
|
||||
WHERE user_emails.email=? COLLATE NOCASE
|
||||
AND user_emails.verified_at IS NOT NULL
|
||||
AND deleted_accounts.user_id IS NULL
|
||||
AND user_status.blocked_at IS NULL
|
||||
AND (user_status.banned_until IS NULL OR user_status.banned_until<=?)
|
||||
`)
|
||||
).all(email, now);
|
||||
if (matchingAccounts.length !== 1)
|
||||
throw new Error(
|
||||
'The email must identify exactly one active verified account.',
|
||||
);
|
||||
|
||||
const targetId = String(matchingAccounts[0].id);
|
||||
const activeAdministrators = await (
|
||||
await database.prepare(`
|
||||
SELECT users.id
|
||||
FROM users
|
||||
LEFT JOIN user_status ON user_status.user_id=users.id
|
||||
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
|
||||
WHERE deleted_accounts.user_id IS NULL
|
||||
AND user_status.blocked_at IS NULL
|
||||
AND (user_status.banned_until IS NULL OR user_status.banned_until<=?)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM user_roles
|
||||
WHERE user_roles.user_id=users.id AND user_roles.role_id='role-admin'
|
||||
)
|
||||
`)
|
||||
).all(now);
|
||||
const targetAlreadyAdministrator = activeAdministrators.some(
|
||||
(administrator) => String(administrator.id) === targetId,
|
||||
);
|
||||
if (activeAdministrators.length > 0 && !targetAlreadyAdministrator)
|
||||
throw new Error('An active full administrator already exists.');
|
||||
|
||||
if (!targetAlreadyAdministrator) {
|
||||
const administratorRole = await (
|
||||
await database.prepare(`
|
||||
SELECT roles.id
|
||||
FROM roles
|
||||
JOIN role_permissions ON role_permissions.role_id=roles.id
|
||||
JOIN permissions ON permissions.id=role_permissions.permission_id
|
||||
WHERE roles.id='role-admin' AND permissions.name='system.admin'
|
||||
LIMIT 1
|
||||
`)
|
||||
).get();
|
||||
if (!administratorRole)
|
||||
throw new Error('The canonical administrator role is unavailable.');
|
||||
|
||||
const activeFactor = await (
|
||||
await database.prepare(
|
||||
"SELECT id FROM mfa_totp_factors WHERE user_id=? AND state='active' LIMIT 1",
|
||||
)
|
||||
).get(targetId);
|
||||
if (!activeFactor)
|
||||
throw new Error('The target account must have an active TOTP factor.');
|
||||
const unusedRecoveryCode = await (
|
||||
await database.prepare(
|
||||
'SELECT id FROM mfa_recovery_codes WHERE user_id=? AND consumed_at IS NULL AND revoked_at IS NULL LIMIT 1',
|
||||
)
|
||||
).get(targetId);
|
||||
if (!unusedRecoveryCode)
|
||||
throw new Error('The target account must have an unused recovery code.');
|
||||
|
||||
const before = {
|
||||
roleIds: (
|
||||
await (
|
||||
await database.prepare(
|
||||
'SELECT role_id FROM user_roles WHERE user_id=? ORDER BY role_id',
|
||||
)
|
||||
).all(targetId)
|
||||
).map((row) => String(row.role_id)),
|
||||
};
|
||||
|
||||
await (
|
||||
await database.prepare(
|
||||
"INSERT OR IGNORE INTO user_roles(user_id,role_id) VALUES(?,'role-admin')",
|
||||
)
|
||||
).run(targetId);
|
||||
await (
|
||||
await database.prepare(
|
||||
"UPDATE users SET role='user' WHERE id=? AND role='admin'",
|
||||
)
|
||||
).run(targetId);
|
||||
|
||||
const targetSessions = await (
|
||||
await database.prepare('SELECT id FROM sessions WHERE user_id=?')
|
||||
).all(targetId);
|
||||
const sessionIds = targetSessions.map((row) => String(row.id));
|
||||
if (sessionIds.length) {
|
||||
const placeholders = sessionIds.map(() => '?').join(',');
|
||||
await (
|
||||
await database.prepare(
|
||||
`UPDATE login_events SET session_ended_at=?,session_end_reason=? WHERE session_id IN (${placeholders})`,
|
||||
)
|
||||
).run(now, 'administrator_bootstrap', ...sessionIds);
|
||||
await (
|
||||
await database.prepare(
|
||||
`DELETE FROM sessions WHERE id IN (${placeholders})`,
|
||||
)
|
||||
).run(...sessionIds);
|
||||
}
|
||||
|
||||
await (
|
||||
await database.prepare(`
|
||||
INSERT INTO audit_log(
|
||||
id,actor_id,entity_type,entity_id,action,before_json,after_json,created_at
|
||||
) VALUES(?,?,?,?,?,?,?,?)
|
||||
`)
|
||||
).run(
|
||||
randomUUID(),
|
||||
targetId,
|
||||
'role_management',
|
||||
targetId,
|
||||
'administrator.bootstrapped',
|
||||
JSON.stringify(before),
|
||||
JSON.stringify({
|
||||
roleId: 'role-admin',
|
||||
sessionsRevoked: sessionIds.length,
|
||||
mfaRequired: true,
|
||||
}),
|
||||
now,
|
||||
);
|
||||
}
|
||||
|
||||
const resultingAdministrators = await (
|
||||
await database.prepare(`
|
||||
SELECT COUNT(DISTINCT users.id) AS count
|
||||
FROM users
|
||||
LEFT JOIN user_status ON user_status.user_id=users.id
|
||||
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
|
||||
WHERE deleted_accounts.user_id IS NULL
|
||||
AND user_status.blocked_at IS NULL
|
||||
AND (user_status.banned_until IS NULL OR user_status.banned_until<=?)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM user_roles
|
||||
WHERE user_roles.user_id=users.id AND user_roles.role_id='role-admin'
|
||||
)
|
||||
`)
|
||||
).get(now);
|
||||
if (Number(resultingAdministrators?.count) < 1)
|
||||
throw new Error('The database must retain an active full administrator.');
|
||||
|
||||
await database.exec('COMMIT');
|
||||
transactionStarted = false;
|
||||
console.log(
|
||||
targetAlreadyAdministrator
|
||||
? 'The target account already has unrestricted administration.'
|
||||
: 'Initial unrestricted administration granted.',
|
||||
);
|
||||
} catch (error) {
|
||||
if (transactionStarted) {
|
||||
try {
|
||||
await database.exec('ROLLBACK');
|
||||
} catch {
|
||||
// Preserve the original failure without exposing database details.
|
||||
}
|
||||
}
|
||||
console.error(
|
||||
error instanceof Error ? error.message : 'Administrator bootstrap failed.',
|
||||
);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await database.close();
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { connect, type Database } from '@tursodatabase/database';
|
||||
|
||||
const run = promisify(execFile);
|
||||
let directory: string;
|
||||
let database: Database;
|
||||
let databasePath: string;
|
||||
|
||||
async function invoke() {
|
||||
try {
|
||||
const result = await run(
|
||||
process.execPath,
|
||||
['scripts/admin-bootstrap.mjs', 'admin@example.com'],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
TDR_DATABASE_PATH: databasePath,
|
||||
NODE_ENV: 'test',
|
||||
},
|
||||
},
|
||||
);
|
||||
return { status: 0, stdout: result.stdout, stderr: result.stderr };
|
||||
} catch (error) {
|
||||
const failure = error as {
|
||||
code?: number;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
};
|
||||
return {
|
||||
status: Number(failure.code ?? 1),
|
||||
stdout: failure.stdout ?? '',
|
||||
stderr: failure.stderr ?? '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe('administrator bootstrap preconditions', () => {
|
||||
beforeEach(async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), 'shd-admin-bootstrap-'));
|
||||
databasePath = join(directory, 'database.db');
|
||||
database = await connect(databasePath);
|
||||
await database.exec(`
|
||||
CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT, role TEXT, created_at TEXT);
|
||||
CREATE TABLE user_emails (user_id TEXT, email TEXT, verified_at TEXT);
|
||||
CREATE TABLE user_status (user_id TEXT PRIMARY KEY, blocked_at TEXT, banned_until TEXT);
|
||||
CREATE TABLE deleted_accounts (user_id TEXT PRIMARY KEY);
|
||||
CREATE TABLE roles (id TEXT PRIMARY KEY, name TEXT);
|
||||
CREATE TABLE permissions (id TEXT PRIMARY KEY, name TEXT);
|
||||
CREATE TABLE role_permissions (role_id TEXT, permission_id TEXT);
|
||||
CREATE TABLE user_roles (user_id TEXT, role_id TEXT, PRIMARY KEY(user_id,role_id));
|
||||
CREATE TABLE mfa_totp_factors (id TEXT PRIMARY KEY, user_id TEXT, state TEXT);
|
||||
CREATE TABLE mfa_recovery_codes (id TEXT PRIMARY KEY, user_id TEXT, consumed_at TEXT, revoked_at TEXT);
|
||||
CREATE TABLE sessions (id TEXT PRIMARY KEY, user_id TEXT);
|
||||
CREATE TABLE login_events (id TEXT PRIMARY KEY, session_id TEXT, session_ended_at TEXT, session_end_reason TEXT);
|
||||
CREATE TABLE audit_log (id TEXT PRIMARY KEY, actor_id TEXT, entity_type TEXT, entity_id TEXT, action TEXT, before_json TEXT, after_json TEXT, created_at TEXT);
|
||||
INSERT INTO users VALUES ('user-1','agent','user','2026-08-07T00:00:00.000Z');
|
||||
INSERT INTO user_emails VALUES ('user-1','admin@example.com','2026-08-07T00:00:00.000Z');
|
||||
INSERT INTO roles VALUES ('role-admin','Admin');
|
||||
INSERT INTO permissions VALUES ('permission-system-admin','system.admin');
|
||||
INSERT INTO role_permissions VALUES ('role-admin','permission-system-admin');
|
||||
`);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await database.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('rejects accounts without both active TOTP and unused recovery code', async () => {
|
||||
await database.close();
|
||||
const result = await invoke();
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain('active TOTP factor');
|
||||
database = await connect(databasePath);
|
||||
const user = await (await database.prepare('SELECT role FROM users')).get();
|
||||
expect(user.role).toBe('user');
|
||||
});
|
||||
|
||||
it('grants only after MFA readiness and records sanitized bootstrap evidence', async () => {
|
||||
await database.exec(`
|
||||
INSERT INTO mfa_totp_factors VALUES ('factor-1','user-1','active');
|
||||
INSERT INTO mfa_recovery_codes VALUES ('code-1','user-1',NULL,NULL);
|
||||
INSERT INTO sessions VALUES ('session-1','user-1');
|
||||
`);
|
||||
await database.close();
|
||||
const result = await invoke();
|
||||
expect(result.status).toBe(0);
|
||||
database = await connect(databasePath);
|
||||
const user = await (await database.prepare('SELECT role FROM users')).get();
|
||||
const role = await (
|
||||
await database.prepare('SELECT role_id FROM user_roles WHERE user_id=?')
|
||||
).get('user-1');
|
||||
const sessions = await (
|
||||
await database.prepare('SELECT COUNT(*) AS count FROM sessions')
|
||||
).get();
|
||||
const audit = await (
|
||||
await database.prepare(
|
||||
'SELECT entity_type,action,after_json FROM audit_log',
|
||||
)
|
||||
).get();
|
||||
expect(user.role).toBe('user');
|
||||
expect(role.role_id).toBe('role-admin');
|
||||
expect(Number(sessions.count)).toBe(0);
|
||||
expect(audit).toMatchObject({
|
||||
entity_type: 'role_management',
|
||||
action: 'administrator.bootstrapped',
|
||||
});
|
||||
expect(String(audit.after_json)).not.toContain('factor-1');
|
||||
expect(String(audit.after_json)).not.toContain('code-1');
|
||||
});
|
||||
|
||||
it('is idempotent for an already canonical administrator', async () => {
|
||||
await database.exec(`
|
||||
INSERT INTO mfa_totp_factors VALUES ('factor-1','user-1','active');
|
||||
INSERT INTO mfa_recovery_codes VALUES ('code-1','user-1',NULL,NULL);
|
||||
`);
|
||||
await database.close();
|
||||
expect((await invoke()).status).toBe(0);
|
||||
database = await connect(databasePath);
|
||||
const firstAuditCount = await (
|
||||
await database.prepare(
|
||||
"SELECT COUNT(*) AS count FROM audit_log WHERE action='administrator.bootstrapped'",
|
||||
)
|
||||
).get();
|
||||
await database.close();
|
||||
expect((await invoke()).status).toBe(0);
|
||||
database = await connect(databasePath);
|
||||
const secondAuditCount = await (
|
||||
await database.prepare(
|
||||
"SELECT COUNT(*) AS count FROM audit_log WHERE action='administrator.bootstrapped'",
|
||||
)
|
||||
).get();
|
||||
expect(Number(firstAuditCount.count)).toBe(1);
|
||||
expect(Number(secondAuditCount.count)).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps only the local development bootstrap command', async () => {
|
||||
const packageJson = JSON.parse(await readFile('package.json', 'utf8')) as {
|
||||
scripts: Record<string, string>;
|
||||
};
|
||||
expect(packageJson.scripts['admin:bootstrap']).toBeTruthy();
|
||||
expect(packageJson.scripts['admin:bootstrap:staging']).toBeUndefined();
|
||||
expect(packageJson.scripts['admin:bootstrap:production']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+707
-9
@@ -45,7 +45,8 @@ const isRoll = (value) =>
|
||||
isFiniteNumber(value.value);
|
||||
const isRollPair = (value) =>
|
||||
Array.isArray(value) && value.length === 2 && value.every(isRoll);
|
||||
const isGearSelection = (value) =>
|
||||
const isTalentTier = (value) => value === 1 || value === 2;
|
||||
const isLegacyGearSelection = (value) =>
|
||||
hasExactKeys(value, [
|
||||
'itemId',
|
||||
'level',
|
||||
@@ -68,7 +69,7 @@ const isGearSelection = (value) =>
|
||||
isRollPair(value.rolls) &&
|
||||
Array.isArray(value.augmentSlots) &&
|
||||
value.augmentSlots.every(isFiniteNumber);
|
||||
const isWeaponSelection = (value) =>
|
||||
const isLegacyWeaponSelection = (value) =>
|
||||
hasExactKeys(value, [
|
||||
'itemId',
|
||||
'level',
|
||||
@@ -88,12 +89,68 @@ const isWeaponSelection = (value) =>
|
||||
(key) => isFiniteNumber(value[key]),
|
||||
) &&
|
||||
isRollPair(value.rolls);
|
||||
const isGearSelection = (value) =>
|
||||
hasExactKeys(value, [
|
||||
'itemId',
|
||||
'level',
|
||||
'brandId',
|
||||
'talentId',
|
||||
'talentTier',
|
||||
'health',
|
||||
'armor',
|
||||
'firepower',
|
||||
'toughness',
|
||||
'engineering',
|
||||
'rolls',
|
||||
'augmentSlots',
|
||||
]) &&
|
||||
['itemId', 'brandId', 'talentId'].every(
|
||||
(key) => typeof value[key] === 'string',
|
||||
) &&
|
||||
isTalentTier(value.talentTier) &&
|
||||
['level', 'health', 'armor', 'firepower', 'toughness', 'engineering'].every(
|
||||
(key) => isFiniteNumber(value[key]),
|
||||
) &&
|
||||
isRollPair(value.rolls) &&
|
||||
Array.isArray(value.augmentSlots) &&
|
||||
value.augmentSlots.every(isFiniteNumber);
|
||||
const isWeaponSelection = (value) =>
|
||||
hasExactKeys(value, [
|
||||
'itemId',
|
||||
'level',
|
||||
'tier',
|
||||
'damage',
|
||||
'firepower',
|
||||
'toughness',
|
||||
'engineering',
|
||||
'talent1Id',
|
||||
'talent1Tier',
|
||||
'talent2Id',
|
||||
'talent2Tier',
|
||||
'exoticTalentTier',
|
||||
'rolls',
|
||||
]) &&
|
||||
['itemId', 'talent1Id', 'talent2Id'].every(
|
||||
(key) => typeof value[key] === 'string',
|
||||
) &&
|
||||
isTalentTier(value.talent1Tier) &&
|
||||
isTalentTier(value.talent2Tier) &&
|
||||
isTalentTier(value.exoticTalentTier) &&
|
||||
['level', 'tier', 'damage', 'firepower', 'toughness', 'engineering'].every(
|
||||
(key) => isFiniteNumber(value[key]),
|
||||
) &&
|
||||
isRollPair(value.rolls);
|
||||
const isChipSelection = (value) =>
|
||||
hasExactKeys(value, ['setId', 'quality', 'primary', 'secondary']) &&
|
||||
typeof value.setId === 'string' &&
|
||||
(value.quality === 'Superior' || value.quality === 'High-end') &&
|
||||
['Standard', 'Specialized', 'Superior', 'High-end'].includes(value.quality) &&
|
||||
isRoll(value.primary) &&
|
||||
isRollPair(value.secondary);
|
||||
Array.isArray(value.secondary) &&
|
||||
value.secondary.length ===
|
||||
({ Standard: 0, Specialized: 1, Superior: 2, 'High-end': 2 }[
|
||||
value.quality
|
||||
] ?? -1) &&
|
||||
value.secondary.every(isRoll);
|
||||
const isProtocolSelection = (value) =>
|
||||
hasExactKeys(value, ['itemId', 'level', 'talentUpgrades']) &&
|
||||
typeof value.itemId === 'string' &&
|
||||
@@ -185,7 +242,7 @@ const isCurrentLoadoutSnapshot = (raw) => {
|
||||
'skillActivations',
|
||||
'specialization',
|
||||
]) &&
|
||||
value.version === 4 &&
|
||||
value.version === 6 &&
|
||||
typeof value.buildName === 'string' &&
|
||||
['maximum', 'custom'].includes(value.calculationMode) &&
|
||||
hasExactKeys(value.gear, gearSlots) &&
|
||||
@@ -207,6 +264,49 @@ const isCurrentLoadoutSnapshot = (raw) => {
|
||||
);
|
||||
};
|
||||
|
||||
const isLegacyLoadoutSnapshot = (raw) => {
|
||||
let value;
|
||||
try {
|
||||
value = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
hasExactKeys(value, [
|
||||
'version',
|
||||
'buildName',
|
||||
'gear',
|
||||
'weapons',
|
||||
'chips',
|
||||
'protocol',
|
||||
'conditionalEffects',
|
||||
'combatProfile',
|
||||
'calculationMode',
|
||||
'skillActivations',
|
||||
'specialization',
|
||||
]) &&
|
||||
value.version === 5 &&
|
||||
typeof value.buildName === 'string' &&
|
||||
['maximum', 'custom'].includes(value.calculationMode) &&
|
||||
hasExactKeys(value.gear, gearSlots) &&
|
||||
gearSlots.every((slot) => isLegacyGearSelection(value.gear[slot])) &&
|
||||
Array.isArray(value.weapons) &&
|
||||
value.weapons.length === 2 &&
|
||||
value.weapons.every(isLegacyWeaponSelection) &&
|
||||
Array.isArray(value.chips) &&
|
||||
value.chips.length === 9 &&
|
||||
value.chips.every(isChipSelection) &&
|
||||
isProtocolSelection(value.protocol) &&
|
||||
isEffectStates(value.conditionalEffects) &&
|
||||
isCombatProfile(value.combatProfile) &&
|
||||
isSkillActivations(value.skillActivations) &&
|
||||
hasExactKeys(value.specialization, ['focusId', 'nodeRanks']) &&
|
||||
typeof value.specialization.focusId === 'string' &&
|
||||
isRecord(value.specialization.nodeRanks) &&
|
||||
Object.values(value.specialization.nodeRanks).every(isFiniteNumber)
|
||||
);
|
||||
};
|
||||
|
||||
const databasePath = resolve(
|
||||
process.env.TDR_DATABASE_PATH || './data/shd-build.db',
|
||||
);
|
||||
@@ -669,6 +769,176 @@ for (const statement of statements) {
|
||||
await database.exec(statement);
|
||||
}
|
||||
|
||||
// Authentication transactions created by older releases did not retain the
|
||||
// primary provider. Keep those rows intact and leave their provider unknown;
|
||||
// new login transactions write the validated provider at creation time.
|
||||
const authenticationTransactionColumns = await (
|
||||
await database.prepare("PRAGMA table_info('authentication_transactions')")
|
||||
).all();
|
||||
if (
|
||||
authenticationTransactionColumns.length &&
|
||||
!authenticationTransactionColumns.some((column) => column.name === 'provider')
|
||||
) {
|
||||
console.log('Adding primary providers to authentication transactions.');
|
||||
await database.exec(
|
||||
"ALTER TABLE authentication_transactions ADD COLUMN provider TEXT CHECK(provider IS NULL OR provider IN ('password','discord','magic_link','passkey'))",
|
||||
);
|
||||
}
|
||||
|
||||
// Move legacy administrator rows onto the canonical relationship before the
|
||||
// legacy users.role projection is cleared. The operation is repeatable and
|
||||
// keeps the existing account IDs and authored data unchanged.
|
||||
const legacyAdministratorRows = await (
|
||||
await database.prepare("SELECT id FROM users WHERE role='admin' ORDER BY id")
|
||||
).all();
|
||||
if (legacyAdministratorRows.length) {
|
||||
const administratorRole = await (
|
||||
await database.prepare("SELECT id FROM roles WHERE id='role-admin'")
|
||||
).get();
|
||||
if (!administratorRole)
|
||||
throw new Error('Cannot migrate legacy administrators without role-admin.');
|
||||
await database.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
for (const user of legacyAdministratorRows) {
|
||||
await (
|
||||
await database.prepare(
|
||||
"INSERT OR IGNORE INTO user_roles(user_id,role_id) VALUES(?,'role-admin')",
|
||||
)
|
||||
).run(String(user.id));
|
||||
}
|
||||
await database.exec("UPDATE users SET role='user' WHERE role='admin'");
|
||||
const remainingLegacyAdministrators = await (
|
||||
await database.prepare(
|
||||
"SELECT COUNT(*) AS count FROM users WHERE role='admin'",
|
||||
)
|
||||
).get();
|
||||
if (Number(remainingLegacyAdministrators?.count) !== 0)
|
||||
throw new Error('Legacy administrator authority could not be cleared.');
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await database.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// MFA rotations keep the current active factor while a replacement is pending.
|
||||
// Older databases used one partial index for both states, which prevented that
|
||||
// safe overlap. Drop it and install the two independent idempotent constraints.
|
||||
const legacyTotpCurrentIndex = await (
|
||||
await database.prepare(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='index' AND name='mfa_totp_factors_one_current_idx'",
|
||||
)
|
||||
).get();
|
||||
if (legacyTotpCurrentIndex) {
|
||||
await database.exec('DROP INDEX mfa_totp_factors_one_current_idx');
|
||||
}
|
||||
await database.exec(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS mfa_totp_factors_one_active_idx ON mfa_totp_factors(user_id) WHERE state='active'",
|
||||
);
|
||||
await database.exec(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS mfa_totp_factors_one_pending_idx ON mfa_totp_factors(user_id) WHERE state='pending'",
|
||||
);
|
||||
|
||||
// Existing sessions were issued before authentication assurance levels were
|
||||
// recorded. They cannot be upgraded safely, so invalidate only those legacy
|
||||
// rows after adding the nullable migration columns. New sessions are written
|
||||
// with a non-null AAL evidence timestamp by the auth session service.
|
||||
const sessionTable = await (
|
||||
await database.prepare(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='sessions'",
|
||||
)
|
||||
).get();
|
||||
if (sessionTable) {
|
||||
let sessionColumns = await (
|
||||
await database.prepare("PRAGMA table_info('sessions')")
|
||||
).all();
|
||||
if (!sessionColumns.some((column) => column.name === 'aal')) {
|
||||
await database.exec(
|
||||
'ALTER TABLE sessions ADD COLUMN aal INTEGER NOT NULL DEFAULT 1 CHECK(aal IN (1,2))',
|
||||
);
|
||||
sessionColumns = [...sessionColumns, { name: 'aal' }];
|
||||
}
|
||||
if (!sessionColumns.some((column) => column.name === 'aal_verified_at')) {
|
||||
await database.exec('ALTER TABLE sessions ADD COLUMN aal_verified_at TEXT');
|
||||
sessionColumns = [...sessionColumns, { name: 'aal_verified_at' }];
|
||||
}
|
||||
if (!sessionColumns.some((column) => column.name === 'aal2_verified_at')) {
|
||||
await database.exec(
|
||||
'ALTER TABLE sessions ADD COLUMN aal2_verified_at TEXT',
|
||||
);
|
||||
}
|
||||
const legacySessions = await (
|
||||
await database.prepare(
|
||||
'SELECT COUNT(*) AS count FROM sessions WHERE aal_verified_at IS NULL',
|
||||
)
|
||||
).get();
|
||||
if (Number(legacySessions?.count) > 0) {
|
||||
console.log(
|
||||
`Invalidating ${Number(legacySessions.count)} legacy session(s) without AAL evidence.`,
|
||||
);
|
||||
await database.exec('DELETE FROM sessions WHERE aal_verified_at IS NULL');
|
||||
}
|
||||
}
|
||||
|
||||
const loginEventsTable = await (
|
||||
await database.prepare(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='login_events'",
|
||||
)
|
||||
).get();
|
||||
if (loginEventsTable) {
|
||||
const loginEventColumns = await (
|
||||
await database.prepare("PRAGMA table_info('login_events')")
|
||||
).all();
|
||||
if (!loginEventColumns.some((column) => column.name === 'aal')) {
|
||||
await database.exec(
|
||||
'ALTER TABLE login_events ADD COLUMN aal INTEGER CHECK(aal IS NULL OR aal IN (1,2))',
|
||||
);
|
||||
}
|
||||
if (!loginEventColumns.some((column) => column.name === 'aal2_verified_at')) {
|
||||
await database.exec(
|
||||
'ALTER TABLE login_events ADD COLUMN aal2_verified_at TEXT',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await database.exec(`CREATE TRIGGER IF NOT EXISTS specialization_ability_skill_chip_links_validate_insert
|
||||
BEFORE INSERT ON specialization_ability_skill_chip_links
|
||||
BEGIN
|
||||
SELECT CASE WHEN EXISTS (
|
||||
SELECT 1 FROM specialization_ability_skill_chip_links link
|
||||
WHERE link.ability_id IN (NEW.ability_id,NEW.compatible_ability_id)
|
||||
OR link.compatible_ability_id IN (NEW.ability_id,NEW.compatible_ability_id)
|
||||
) THEN RAISE(ABORT,'Each ability can share one Skill Chip pool') END;
|
||||
SELECT CASE WHEN NOT EXISTS (
|
||||
SELECT 1 FROM specialization_abilities ability
|
||||
JOIN specialization_abilities compatible
|
||||
ON compatible.id=NEW.compatible_ability_id
|
||||
WHERE ability.id=NEW.ability_id
|
||||
AND ability.active=1
|
||||
AND compatible.active=1
|
||||
AND ability.specialization_id=compatible.specialization_id
|
||||
) THEN RAISE(ABORT,'Skill Chip sharing requires active abilities from one specialization') END;
|
||||
END`);
|
||||
await database.exec(`CREATE TRIGGER IF NOT EXISTS specialization_ability_skill_chip_links_validate_update
|
||||
BEFORE UPDATE ON specialization_ability_skill_chip_links
|
||||
BEGIN
|
||||
SELECT CASE WHEN EXISTS (
|
||||
SELECT 1 FROM specialization_ability_skill_chip_links link
|
||||
WHERE link.rowid<>OLD.rowid
|
||||
AND (link.ability_id IN (NEW.ability_id,NEW.compatible_ability_id)
|
||||
OR link.compatible_ability_id IN (NEW.ability_id,NEW.compatible_ability_id))
|
||||
) THEN RAISE(ABORT,'Each ability can share one Skill Chip pool') END;
|
||||
SELECT CASE WHEN NOT EXISTS (
|
||||
SELECT 1 FROM specialization_abilities ability
|
||||
JOIN specialization_abilities compatible
|
||||
ON compatible.id=NEW.compatible_ability_id
|
||||
WHERE ability.id=NEW.ability_id
|
||||
AND ability.active=1
|
||||
AND compatible.active=1
|
||||
AND ability.specialization_id=compatible.specialization_id
|
||||
) THEN RAISE(ABORT,'Skill Chip sharing requires active abilities from one specialization') END;
|
||||
END`);
|
||||
|
||||
const articleRevisionColumns = await (
|
||||
await database.prepare("PRAGMA table_info('article_revisions')")
|
||||
).all();
|
||||
@@ -2004,10 +2274,12 @@ if (
|
||||
user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
provider TEXT NOT NULL CHECK(provider IN ('password', 'discord', 'magic_link', 'passkey')),
|
||||
succeeded INTEGER NOT NULL CHECK(succeeded IN (0, 1)),
|
||||
aal INTEGER CHECK(aal IS NULL OR aal IN (1,2)),
|
||||
aal2_verified_at TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
)`);
|
||||
await database.exec(`INSERT INTO login_events(id,user_id,provider,succeeded,created_at)
|
||||
SELECT id,user_id,provider,succeeded,created_at FROM login_events_legacy`);
|
||||
await database.exec(`INSERT INTO login_events(id,user_id,provider,succeeded,aal,aal2_verified_at,created_at)
|
||||
SELECT id,user_id,provider,succeeded,NULL,NULL,created_at FROM login_events_legacy`);
|
||||
await database.exec('DROP TABLE login_events_legacy');
|
||||
await database.exec(
|
||||
'CREATE INDEX IF NOT EXISTS login_events_user_created_idx ON login_events(user_id, created_at DESC)',
|
||||
@@ -2032,6 +2304,8 @@ for (const [name, definition] of [
|
||||
['session_ended_at', 'TEXT'],
|
||||
['session_end_reason', 'TEXT'],
|
||||
['sensitive_purged_at', 'TEXT'],
|
||||
['aal', 'INTEGER CHECK(aal IS NULL OR aal IN (1,2))'],
|
||||
['aal2_verified_at', 'TEXT'],
|
||||
]) {
|
||||
if (!loginEventColumns.some((column) => column.name === name)) {
|
||||
await database.exec(
|
||||
@@ -2040,6 +2314,20 @@ for (const [name, definition] of [
|
||||
}
|
||||
}
|
||||
|
||||
// Earlier releases logged the primary factor as a successful login even when
|
||||
// the AAL2 challenge was still pending. Retain those events as failure
|
||||
// telemetry, but prevent abandoned MFA challenges from remaining successful.
|
||||
await (
|
||||
await database.prepare(`
|
||||
UPDATE login_events
|
||||
SET succeeded=0,
|
||||
failure_reason=COALESCE(failure_reason,'mfa_required'),
|
||||
aal=COALESCE(aal,1)
|
||||
WHERE succeeded=1
|
||||
AND instr(',' || COALESCE(risk_flags,'') || ',', ',primary_authenticated_mfa_required,')>0
|
||||
`)
|
||||
).run();
|
||||
|
||||
const proposalColumns = await (
|
||||
await database.prepare("PRAGMA table_info('data_change_proposals')")
|
||||
).all();
|
||||
@@ -2392,6 +2680,32 @@ if (
|
||||
);
|
||||
}
|
||||
|
||||
const weaponModSlotIconDefinition = await (
|
||||
await database.prepare(
|
||||
"SELECT sql FROM sqlite_master WHERE type='table' AND name='game_data_icons'",
|
||||
)
|
||||
).get();
|
||||
if (
|
||||
typeof weaponModSlotIconDefinition?.sql === 'string' &&
|
||||
!weaponModSlotIconDefinition.sql.includes("'weapon_mod_slot'")
|
||||
) {
|
||||
console.log('Adding shared Weapon Mod slot icon storage support.');
|
||||
await database.exec(`CREATE TABLE game_data_icons_weapon_mod_slots (
|
||||
entity_type TEXT NOT NULL CHECK(entity_type IN ('talent', 'brand_set', 'os_protocol', 'skill_chip', 'specialization', 'specialization_ability', 'specialization_node', 'weapon_mod_slot')),
|
||||
entity_id TEXT NOT NULL,
|
||||
storage_key TEXT NOT NULL UNIQUE,
|
||||
byte_size INTEGER NOT NULL CHECK(byte_size > 0),
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY(entity_type, entity_id)
|
||||
)`);
|
||||
await database.exec(`INSERT INTO game_data_icons_weapon_mod_slots
|
||||
SELECT entity_type,entity_id,storage_key,byte_size,updated_at FROM game_data_icons`);
|
||||
await database.exec('DROP TABLE game_data_icons');
|
||||
await database.exec(
|
||||
'ALTER TABLE game_data_icons_weapon_mod_slots RENAME TO game_data_icons',
|
||||
);
|
||||
}
|
||||
|
||||
const managedImageColumns = await (
|
||||
await database.prepare("PRAGMA table_info('managed_images')")
|
||||
).all();
|
||||
@@ -2433,6 +2747,7 @@ const iconLibraryCategory = {
|
||||
specialization: 'Specializations',
|
||||
specialization_ability: 'Specializations',
|
||||
specialization_node: 'Specializations',
|
||||
weapon_mod_slot: 'Weapon Mods',
|
||||
};
|
||||
const iconLibraryLabel = {
|
||||
talent: 'Talent',
|
||||
@@ -2442,6 +2757,17 @@ const iconLibraryLabel = {
|
||||
specialization: 'Specialization',
|
||||
specialization_ability: 'Specialization Ability',
|
||||
specialization_node: 'Specialization Tree Node',
|
||||
weapon_mod_slot: 'Weapon Mod Slot',
|
||||
};
|
||||
const weaponModSlotNames = {
|
||||
'28ffc3b1-5d98-4273-a9af-67dbff3e5c76': 'Optics',
|
||||
'c4598f51-2cb0-4a20-a3a7-6d8983a26598': 'Muzzle',
|
||||
'85ca3493-1871-4d22-950a-ed718fd547e0': 'Underbarrel',
|
||||
'4d809a35-8285-4bff-be98-a0effe1d257e': 'Magazine',
|
||||
'0e157cfe-975c-441d-8f49-72983161e7b1': 'Barrel',
|
||||
'551fd7c8-3e5b-4c89-8437-9b901e89e8aa': 'Upper Receiver',
|
||||
'72573367-6e91-4312-9cc3-710ede4f078e': 'Lower Receiver',
|
||||
'8dda5edb-6079-4a23-a556-cd9be4521c70': 'Trigger',
|
||||
};
|
||||
const normalizeLibraryTags = (values) =>
|
||||
[
|
||||
@@ -2532,6 +2858,13 @@ const linkedIconMetadata = async (entityType, entityId) => {
|
||||
],
|
||||
};
|
||||
}
|
||||
case 'weapon_mod_slot': {
|
||||
const name = weaponModSlotNames[entityId];
|
||||
return {
|
||||
name: name ? `${name} Slot` : null,
|
||||
tags: ['weapon mod', 'slot', name],
|
||||
};
|
||||
}
|
||||
default:
|
||||
return { name: null, tags: [] };
|
||||
}
|
||||
@@ -2912,6 +3245,302 @@ if (
|
||||
await database.exec('PRAGMA foreign_keys=ON');
|
||||
}
|
||||
|
||||
for (const kind of ['weapon', 'gear']) {
|
||||
const optionTable = `${kind}_mod_secondary_options`;
|
||||
const valueTable = `${kind}_mod_secondary_quality_values`;
|
||||
const optionColumns = await (
|
||||
await database.prepare(`PRAGMA table_info('${optionTable}')`)
|
||||
).all();
|
||||
if (!optionColumns.some((column) => column.name === `${kind}_mod_id`))
|
||||
continue;
|
||||
|
||||
const conflictingRanges = await (
|
||||
await database.prepare(`SELECT stat_attribute_id,quality,
|
||||
COUNT(DISTINCT CAST(min_value AS TEXT)||'/'||CAST(max_value AS TEXT)) AS variants
|
||||
FROM ${valueTable}
|
||||
WHERE quality IN ('Specialized','Superior','High-end')
|
||||
GROUP BY stat_attribute_id,quality HAVING variants>1`)
|
||||
).all();
|
||||
if (conflictingRanges.length) {
|
||||
throw new Error(
|
||||
`Cannot create the shared ${kind} Mod secondary pool because existing per-Mod ranges disagree. Reconcile ${conflictingRanges.map((row) => `${row.stat_attribute_id}/${row.quality}`).join(', ')} first.`,
|
||||
);
|
||||
}
|
||||
|
||||
const incompleteOptions = await (
|
||||
await database.prepare(`SELECT options.stat_attribute_id,
|
||||
COUNT(DISTINCT CASE WHEN values_row.quality IN ('Specialized','Superior','High-end') THEN values_row.quality END) AS qualities
|
||||
FROM ${optionTable} options
|
||||
LEFT JOIN ${valueTable} values_row ON values_row.${kind}_mod_id=options.${kind}_mod_id
|
||||
AND values_row.stat_attribute_id=options.stat_attribute_id
|
||||
GROUP BY options.${kind}_mod_id,options.stat_attribute_id HAVING qualities<>3`)
|
||||
).all();
|
||||
if (incompleteOptions.length) {
|
||||
throw new Error(
|
||||
`Cannot create the shared ${kind} Mod secondary pool because ${incompleteOptions.map((row) => row.stat_attribute_id).join(', ')} does not have all applicable quality ranges.`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Converting ${kind} Mod secondary Attributes to one shared level-scaled pool.`,
|
||||
);
|
||||
await database.exec('PRAGMA foreign_keys=OFF');
|
||||
await database.exec('BEGIN');
|
||||
try {
|
||||
await database.exec(`CREATE TABLE ${optionTable}_shared (
|
||||
stat_attribute_id TEXT PRIMARY KEY REFERENCES stat_attributes(id)
|
||||
)`);
|
||||
await database.exec(`CREATE TABLE ${valueTable}_shared (
|
||||
stat_attribute_id TEXT NOT NULL,
|
||||
quality TEXT NOT NULL CHECK(quality IN ('Specialized','Superior','High-end')),
|
||||
level_1_min REAL NOT NULL,
|
||||
level_1_max REAL NOT NULL,
|
||||
level_20_min REAL NOT NULL,
|
||||
level_20_max REAL NOT NULL,
|
||||
PRIMARY KEY(stat_attribute_id,quality),
|
||||
FOREIGN KEY(stat_attribute_id) REFERENCES ${optionTable}_shared(stat_attribute_id) ON DELETE CASCADE,
|
||||
CHECK(level_1_max>=level_1_min),
|
||||
CHECK(level_20_max>=level_20_min),
|
||||
CHECK(level_20_min>=level_1_min),
|
||||
CHECK(level_20_max>=level_1_max)
|
||||
)`);
|
||||
await database.exec(`INSERT INTO ${optionTable}_shared(stat_attribute_id)
|
||||
SELECT DISTINCT stat_attribute_id FROM ${optionTable}`);
|
||||
await database.exec(`INSERT INTO ${valueTable}_shared(
|
||||
stat_attribute_id,quality,level_1_min,level_1_max,level_20_min,level_20_max
|
||||
) SELECT stat_attribute_id,quality,MIN(min_value),MAX(max_value),MIN(min_value),MAX(max_value)
|
||||
FROM ${valueTable}
|
||||
WHERE quality IN ('Specialized','Superior','High-end')
|
||||
GROUP BY stat_attribute_id,quality`);
|
||||
await database.exec(`DROP TABLE ${valueTable}`);
|
||||
await database.exec(`DROP TABLE ${optionTable}`);
|
||||
await database.exec(
|
||||
`ALTER TABLE ${optionTable}_shared RENAME TO ${optionTable}`,
|
||||
);
|
||||
await database.exec(
|
||||
`ALTER TABLE ${valueTable}_shared RENAME TO ${valueTable}`,
|
||||
);
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await database.exec('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
await database.exec('PRAGMA foreign_keys=ON');
|
||||
}
|
||||
}
|
||||
|
||||
const skillChipPrimaryOptionColumns = await (
|
||||
await database.prepare("PRAGMA table_info('skill_chip_primary_options')")
|
||||
).all();
|
||||
if (
|
||||
skillChipPrimaryOptionColumns.some(
|
||||
(column) => column.name === 'skill_chip_set_id',
|
||||
)
|
||||
) {
|
||||
const fixedSkillChipAttributes = await (
|
||||
await database.prepare(`SELECT COUNT(*) AS total,
|
||||
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active
|
||||
FROM stat_attributes
|
||||
WHERE name IN ('Skill Duration','Skill Radius','Skill Cooldown Recovery','Skill Intensity')`)
|
||||
).get();
|
||||
if (
|
||||
Number(fixedSkillChipAttributes?.total) !== 4 ||
|
||||
Number(fixedSkillChipAttributes?.active) !== 4
|
||||
)
|
||||
throw new Error(
|
||||
'Skill Chip profile migration requires exactly four active fixed primary Attributes.',
|
||||
);
|
||||
console.log(
|
||||
'Converting Skill Chip primary and secondary options to shared quality profiles.',
|
||||
);
|
||||
await database.exec('PRAGMA foreign_keys=OFF');
|
||||
await database.exec('BEGIN');
|
||||
try {
|
||||
await database.exec(
|
||||
'DROP TABLE IF EXISTS skill_chip_primary_quality_values',
|
||||
);
|
||||
await database.exec(
|
||||
'DROP TABLE IF EXISTS skill_chip_secondary_quality_values',
|
||||
);
|
||||
await database.exec(`CREATE TABLE skill_chip_primary_options_shared (
|
||||
stat_attribute_id TEXT PRIMARY KEY REFERENCES stat_attributes(id)
|
||||
)`);
|
||||
await database.exec(`CREATE TABLE skill_chip_primary_quality_values_shared (
|
||||
stat_attribute_id TEXT NOT NULL,
|
||||
quality TEXT NOT NULL CHECK(quality IN ('Standard','Specialized','Superior','High-end')),
|
||||
min_value REAL NOT NULL,
|
||||
max_value REAL NOT NULL,
|
||||
PRIMARY KEY(stat_attribute_id,quality),
|
||||
FOREIGN KEY(stat_attribute_id) REFERENCES skill_chip_primary_options_shared(stat_attribute_id) ON DELETE CASCADE,
|
||||
CHECK(max_value>=min_value),
|
||||
CHECK(min_value>=0)
|
||||
)`);
|
||||
await database.exec(`CREATE TABLE skill_chip_secondary_options_shared (
|
||||
stat_attribute_id TEXT PRIMARY KEY REFERENCES stat_attributes(id)
|
||||
)`);
|
||||
await database.exec(`CREATE TABLE skill_chip_secondary_quality_values_shared (
|
||||
stat_attribute_id TEXT NOT NULL,
|
||||
quality TEXT NOT NULL CHECK(quality IN ('Specialized','Superior','High-end')),
|
||||
min_value REAL NOT NULL,
|
||||
max_value REAL NOT NULL,
|
||||
PRIMARY KEY(stat_attribute_id,quality),
|
||||
FOREIGN KEY(stat_attribute_id) REFERENCES skill_chip_secondary_options_shared(stat_attribute_id) ON DELETE CASCADE,
|
||||
CHECK(max_value>=min_value),
|
||||
CHECK(min_value>=0)
|
||||
)`);
|
||||
await database.exec(`INSERT INTO skill_chip_primary_options_shared(stat_attribute_id)
|
||||
SELECT id FROM stat_attributes
|
||||
WHERE name IN ('Skill Duration','Skill Radius','Skill Cooldown Recovery','Skill Intensity')
|
||||
AND active=1`);
|
||||
await database.exec(`INSERT INTO skill_chip_primary_quality_values_shared(
|
||||
stat_attribute_id,quality,min_value,max_value
|
||||
) SELECT attributes.id,qualities.quality,attributes.skill_chip_min,attributes.skill_chip_max
|
||||
FROM stat_attributes attributes
|
||||
CROSS JOIN (
|
||||
SELECT 'Standard' quality UNION ALL SELECT 'Specialized'
|
||||
UNION ALL SELECT 'Superior' UNION ALL SELECT 'High-end'
|
||||
) qualities
|
||||
WHERE attributes.name IN ('Skill Duration','Skill Radius','Skill Cooldown Recovery','Skill Intensity')
|
||||
AND attributes.active=1
|
||||
AND attributes.skill_chip_min IS NOT NULL
|
||||
AND attributes.skill_chip_max IS NOT NULL`);
|
||||
await database.exec(`INSERT INTO skill_chip_secondary_options_shared(stat_attribute_id)
|
||||
SELECT DISTINCT stat_attribute_id FROM skill_chip_secondary_options`);
|
||||
await database.exec(`INSERT INTO skill_chip_secondary_quality_values_shared(
|
||||
stat_attribute_id,quality,min_value,max_value
|
||||
) SELECT options.stat_attribute_id,qualities.quality,attributes.skill_chip_min,attributes.skill_chip_max
|
||||
FROM (SELECT DISTINCT stat_attribute_id FROM skill_chip_secondary_options) options
|
||||
JOIN stat_attributes attributes ON attributes.id=options.stat_attribute_id
|
||||
CROSS JOIN (
|
||||
SELECT 'Specialized' quality UNION ALL SELECT 'Superior' UNION ALL SELECT 'High-end'
|
||||
) qualities
|
||||
WHERE attributes.skill_chip_min IS NOT NULL
|
||||
AND attributes.skill_chip_max IS NOT NULL`);
|
||||
await database.exec('DROP TABLE skill_chip_primary_options');
|
||||
await database.exec('DROP TABLE skill_chip_secondary_options');
|
||||
await database.exec(
|
||||
'ALTER TABLE skill_chip_primary_options_shared RENAME TO skill_chip_primary_options',
|
||||
);
|
||||
await database.exec(
|
||||
'ALTER TABLE skill_chip_primary_quality_values_shared RENAME TO skill_chip_primary_quality_values',
|
||||
);
|
||||
await database.exec(
|
||||
'ALTER TABLE skill_chip_secondary_options_shared RENAME TO skill_chip_secondary_options',
|
||||
);
|
||||
await database.exec(
|
||||
'ALTER TABLE skill_chip_secondary_quality_values_shared RENAME TO skill_chip_secondary_quality_values',
|
||||
);
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await database.exec('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
await database.exec('PRAGMA foreign_keys=ON');
|
||||
}
|
||||
}
|
||||
await database.exec(`INSERT OR IGNORE INTO skill_chip_primary_options(stat_attribute_id)
|
||||
SELECT id FROM stat_attributes
|
||||
WHERE active=1 AND name IN (
|
||||
'Skill Duration','Skill Radius','Skill Cooldown Recovery','Skill Intensity'
|
||||
)`);
|
||||
|
||||
const skillChipRangeAttributeColumns = await (
|
||||
await database.prepare("PRAGMA table_info('stat_attributes')")
|
||||
).all();
|
||||
if (
|
||||
skillChipRangeAttributeColumns.some(
|
||||
(column) => column.name === 'skill_chip_min',
|
||||
)
|
||||
) {
|
||||
console.log(
|
||||
'Removing obsolete Attribute-level Skill Chip ranges in favor of shared quality profiles.',
|
||||
);
|
||||
await database.exec('PRAGMA foreign_keys=OFF');
|
||||
await database.exec('BEGIN');
|
||||
try {
|
||||
await database.exec(
|
||||
'DROP INDEX IF EXISTS stat_attributes_name_unit_nocase_idx',
|
||||
);
|
||||
await database.exec(`CREATE TABLE stat_attributes_current (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
type TEXT NOT NULL CHECK(type IN ('Offensive','Defensive')),
|
||||
category TEXT NOT NULL CHECK(category IN ('Weapon','Skill','Character')),
|
||||
calculation_type TEXT NOT NULL CHECK(calculation_type IN ('Additive','Multiplicative')),
|
||||
unit TEXT NOT NULL DEFAULT '',
|
||||
gear_min REAL,
|
||||
gear_max REAL,
|
||||
gear_reference_level INTEGER NOT NULL DEFAULT 50 CHECK(gear_reference_level BETWEEN 1 AND 50),
|
||||
weapon_min REAL,
|
||||
weapon_max REAL,
|
||||
weapon_reference_level INTEGER NOT NULL DEFAULT 50 CHECK(weapon_reference_level BETWEEN 1 AND 50),
|
||||
cap_value REAL,
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
CHECK((gear_min IS NULL AND gear_max IS NULL) OR (gear_min IS NOT NULL AND gear_max IS NOT NULL AND gear_min<=gear_max)),
|
||||
CHECK((weapon_min IS NULL AND weapon_max IS NULL) OR (weapon_min IS NOT NULL AND weapon_max IS NOT NULL AND weapon_min<=weapon_max)),
|
||||
CHECK(cap_value IS NULL OR cap_value>=0)
|
||||
)`);
|
||||
await database.exec(`INSERT INTO stat_attributes_current(
|
||||
id,name,description,type,category,calculation_type,unit,
|
||||
gear_min,gear_max,gear_reference_level,weapon_min,weapon_max,weapon_reference_level,
|
||||
cap_value,active,created_at,updated_at
|
||||
) SELECT id,name,description,type,category,calculation_type,unit,
|
||||
gear_min,gear_max,gear_reference_level,weapon_min,weapon_max,weapon_reference_level,
|
||||
cap_value,active,created_at,updated_at FROM stat_attributes`);
|
||||
await database.exec('DROP TABLE stat_attributes');
|
||||
await database.exec(
|
||||
'ALTER TABLE stat_attributes_current RENAME TO stat_attributes',
|
||||
);
|
||||
await database.exec(
|
||||
'CREATE UNIQUE INDEX stat_attributes_name_unit_nocase_idx ON stat_attributes(name COLLATE NOCASE,unit)',
|
||||
);
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await database.exec('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
await database.exec('PRAGMA foreign_keys=ON');
|
||||
}
|
||||
}
|
||||
|
||||
const [loadoutGearRows, loadoutWeaponRows, loadoutTalentTierRows] =
|
||||
await Promise.all([
|
||||
(await database.prepare('SELECT id,tier FROM gear')).all(),
|
||||
(await database.prepare('SELECT id,exotic_talent_id FROM weapons')).all(),
|
||||
(await database.prepare('SELECT talent_id,tier FROM talent_tiers')).all(),
|
||||
]);
|
||||
const migrateLegacyLoadoutSnapshot = (raw) => {
|
||||
if (!isLegacyLoadoutSnapshot(raw)) return null;
|
||||
const value =
|
||||
typeof raw === 'string' ? JSON.parse(raw) : structuredClone(raw);
|
||||
for (const slot of gearSlots) {
|
||||
const item = loadoutGearRows.find(
|
||||
(entry) => String(entry.id) === String(value.gear[slot].itemId),
|
||||
);
|
||||
value.gear[slot].talentTier = Number(item?.tier) >= 2 ? 2 : 1;
|
||||
}
|
||||
for (const selection of value.weapons) {
|
||||
const inheritedTier = Number(selection.tier) >= 2 ? 2 : 1;
|
||||
selection.talent1Tier = inheritedTier;
|
||||
selection.talent2Tier = inheritedTier;
|
||||
const weapon = loadoutWeaponRows.find(
|
||||
(entry) => String(entry.id) === String(selection.itemId),
|
||||
);
|
||||
const exoticTalentId = String(weapon?.exotic_talent_id ?? '');
|
||||
const hasExoticTierTwo = loadoutTalentTierRows.some(
|
||||
(entry) =>
|
||||
String(entry.talent_id) === exoticTalentId && entry.tier === 'II',
|
||||
);
|
||||
selection.exoticTalentTier =
|
||||
inheritedTier === 2 && hasExoticTierTwo ? 2 : 1;
|
||||
}
|
||||
value.version = 6;
|
||||
return isCurrentLoadoutSnapshot(value) ? JSON.stringify(value) : null;
|
||||
};
|
||||
|
||||
const buildColumns = await (
|
||||
await database.prepare("PRAGMA table_info('builds')")
|
||||
).all();
|
||||
@@ -2926,9 +3555,34 @@ if (buildColumns.length) {
|
||||
await database.exec(
|
||||
"UPDATE builds SET visibility='private' WHERE visibility='draft'",
|
||||
);
|
||||
const builds = await (
|
||||
let builds = await (
|
||||
await database.prepare('SELECT id,loadout_json FROM builds')
|
||||
).all();
|
||||
const migratedBuilds = builds
|
||||
.map((build) => ({
|
||||
id: String(build.id),
|
||||
loadout: migrateLegacyLoadoutSnapshot(build.loadout_json),
|
||||
}))
|
||||
.filter((build) => build.loadout);
|
||||
if (migratedBuilds.length) {
|
||||
console.log(
|
||||
`Migrating ${migratedBuilds.length} build loadout snapshot(s) to version 6 Talent tiers.`,
|
||||
);
|
||||
await database.exec('BEGIN');
|
||||
try {
|
||||
for (const build of migratedBuilds)
|
||||
await (
|
||||
await database.prepare('UPDATE builds SET loadout_json=? WHERE id=?')
|
||||
).run(build.loadout, build.id);
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await database.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
builds = await (
|
||||
await database.prepare('SELECT id,loadout_json FROM builds')
|
||||
).all();
|
||||
}
|
||||
const obsoleteBuildIds = builds
|
||||
.filter((build) => !isCurrentLoadoutSnapshot(build.loadout_json))
|
||||
.map((build) => String(build.id));
|
||||
@@ -3305,11 +3959,55 @@ if (
|
||||
);
|
||||
}
|
||||
|
||||
const calibrationCases = await (
|
||||
const outcomeProbabilityColumns = await (
|
||||
await database.prepare("PRAGMA table_info('conditional_effect_outcomes')")
|
||||
).all();
|
||||
if (
|
||||
!outcomeProbabilityColumns.some(
|
||||
(column) => column.name === 'probability_percent',
|
||||
)
|
||||
) {
|
||||
await database.exec(
|
||||
'ALTER TABLE conditional_effect_outcomes ADD COLUMN probability_percent REAL CHECK(probability_percent IS NULL OR (probability_percent>=0 AND probability_percent<=100))',
|
||||
);
|
||||
}
|
||||
|
||||
let calibrationCases = await (
|
||||
await database.prepare(
|
||||
'SELECT id,loadout_snapshot_json FROM calculation_calibration_cases',
|
||||
)
|
||||
).all();
|
||||
const migratedCalibrationCases = calibrationCases
|
||||
.map((calibrationCase) => ({
|
||||
id: String(calibrationCase.id),
|
||||
loadout: migrateLegacyLoadoutSnapshot(
|
||||
calibrationCase.loadout_snapshot_json,
|
||||
),
|
||||
}))
|
||||
.filter((calibrationCase) => calibrationCase.loadout);
|
||||
if (migratedCalibrationCases.length) {
|
||||
console.log(
|
||||
`Migrating ${migratedCalibrationCases.length} calibration snapshot(s) to version 6 Talent tiers.`,
|
||||
);
|
||||
await database.exec('BEGIN');
|
||||
try {
|
||||
for (const calibrationCase of migratedCalibrationCases)
|
||||
await (
|
||||
await database.prepare(
|
||||
'UPDATE calculation_calibration_cases SET loadout_snapshot_json=? WHERE id=?',
|
||||
)
|
||||
).run(calibrationCase.loadout, calibrationCase.id);
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await database.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
calibrationCases = await (
|
||||
await database.prepare(
|
||||
'SELECT id,loadout_snapshot_json FROM calculation_calibration_cases',
|
||||
)
|
||||
).all();
|
||||
}
|
||||
const obsoleteCalibrationIds = calibrationCases
|
||||
.filter(
|
||||
(calibrationCase) =>
|
||||
|
||||
+72
-2
@@ -44,21 +44,91 @@ for (const table of backup.tables) {
|
||||
}
|
||||
|
||||
let restoredRows = 0;
|
||||
const attributeTable = backup.tables.find(
|
||||
(table) => table.name === 'stat_attributes',
|
||||
);
|
||||
const legacySkillChipProfiles = attributeTable?.rows.some(
|
||||
(row) => 'skill_chip_min' in row || 'skill_chip_max' in row,
|
||||
);
|
||||
const legacySkillChipRanges = new Map(
|
||||
(attributeTable?.rows ?? []).map((row) => [
|
||||
String(row.id),
|
||||
{ min: row.skill_chip_min, max: row.skill_chip_max },
|
||||
]),
|
||||
);
|
||||
const restoredLegacySecondaries = new Set();
|
||||
await database.exec('BEGIN');
|
||||
try {
|
||||
for (const table of backup.tables) {
|
||||
for (const row of table.rows) {
|
||||
const columns = Object.keys(row);
|
||||
if (
|
||||
legacySkillChipProfiles &&
|
||||
table.name === 'skill_chip_primary_options'
|
||||
)
|
||||
continue;
|
||||
const restoredRow = { ...row };
|
||||
if (legacySkillChipProfiles && table.name === 'stat_attributes') {
|
||||
delete restoredRow.skill_chip_min;
|
||||
delete restoredRow.skill_chip_max;
|
||||
}
|
||||
if (
|
||||
legacySkillChipProfiles &&
|
||||
table.name === 'skill_chip_secondary_options'
|
||||
) {
|
||||
delete restoredRow.skill_chip_set_id;
|
||||
const statId = String(restoredRow.stat_attribute_id ?? '');
|
||||
if (restoredLegacySecondaries.has(statId)) continue;
|
||||
restoredLegacySecondaries.add(statId);
|
||||
}
|
||||
const columns = Object.keys(restoredRow);
|
||||
if (!columns.length) continue;
|
||||
const columnSql = columns.map(quoteIdentifier).join(', ');
|
||||
const placeholders = columns.map(() => '?').join(', ');
|
||||
const insert = await database.prepare(
|
||||
`INSERT INTO ${quoteIdentifier(table.name)} (${columnSql}) VALUES (${placeholders})`,
|
||||
);
|
||||
await insert.run(...columns.map((column) => row[column]));
|
||||
await insert.run(...columns.map((column) => restoredRow[column]));
|
||||
restoredRows++;
|
||||
}
|
||||
}
|
||||
if (legacySkillChipProfiles) {
|
||||
const primaryAttributes = await (
|
||||
await database.prepare(`SELECT id,active FROM stat_attributes WHERE name IN (
|
||||
'Skill Duration','Skill Radius','Skill Cooldown Recovery','Skill Intensity'
|
||||
)`)
|
||||
).all();
|
||||
if (
|
||||
primaryAttributes.length !== 4 ||
|
||||
primaryAttributes.some((attribute) => Number(attribute.active) !== 1)
|
||||
)
|
||||
throw new Error(
|
||||
'Legacy restore requires exactly four active fixed Skill Chip primary Attributes.',
|
||||
);
|
||||
for (const attribute of primaryAttributes) {
|
||||
const statId = String(attribute.id);
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO skill_chip_primary_options(stat_attribute_id) VALUES(?)',
|
||||
)
|
||||
).run(statId);
|
||||
const range = legacySkillChipRanges.get(statId);
|
||||
if (!range || range.min === null || range.max === null) continue;
|
||||
for (const quality of ['Standard', 'Specialized', 'Superior', 'High-end'])
|
||||
await (
|
||||
await database.prepare(`INSERT INTO skill_chip_primary_quality_values
|
||||
(stat_attribute_id,quality,min_value,max_value) VALUES(?,?,?,?)`)
|
||||
).run(statId, quality, range.min, range.max);
|
||||
}
|
||||
for (const statId of restoredLegacySecondaries) {
|
||||
const range = legacySkillChipRanges.get(statId);
|
||||
if (!range || range.min === null || range.max === null) continue;
|
||||
for (const quality of ['Specialized', 'Superior', 'High-end'])
|
||||
await (
|
||||
await database.prepare(`INSERT INTO skill_chip_secondary_quality_values
|
||||
(stat_attribute_id,quality,min_value,max_value) VALUES(?,?,?,?)`)
|
||||
).run(statId, quality, range.min, range.max);
|
||||
}
|
||||
}
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await database.exec('ROLLBACK');
|
||||
|
||||
@@ -98,8 +98,6 @@ try {
|
||||
weapon_min: record.weaponRange?.[0] ?? existing?.weapon_min ?? null,
|
||||
weapon_max: record.weaponRange?.[1] ?? existing?.weapon_max ?? null,
|
||||
weapon_reference_level: Number(existing?.weapon_reference_level ?? 50),
|
||||
skill_chip_min: existing?.skill_chip_min ?? null,
|
||||
skill_chip_max: existing?.skill_chip_max ?? null,
|
||||
cap_value: existing?.cap_value ?? null,
|
||||
active: 1,
|
||||
created_at: String(existing?.created_at ?? now),
|
||||
@@ -117,7 +115,7 @@ try {
|
||||
await (
|
||||
await database.prepare(`UPDATE stat_attributes SET
|
||||
name=?,description=?,type=?,category=?,calculation_type=?,unit=?,
|
||||
gear_min=?,gear_max=?,weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?,
|
||||
gear_min=?,gear_max=?,weapon_min=?,weapon_max=?,
|
||||
cap_value=?,active=1,updated_at=? WHERE id=?`)
|
||||
).run(
|
||||
next.name,
|
||||
@@ -130,8 +128,6 @@ try {
|
||||
next.gear_max,
|
||||
next.weapon_min,
|
||||
next.weapon_max,
|
||||
next.skill_chip_min,
|
||||
next.skill_chip_max,
|
||||
next.cap_value,
|
||||
now,
|
||||
id,
|
||||
@@ -142,8 +138,8 @@ try {
|
||||
await database.prepare(`INSERT INTO stat_attributes(
|
||||
id,name,description,type,category,calculation_type,unit,
|
||||
gear_min,gear_max,gear_reference_level,weapon_min,weapon_max,weapon_reference_level,
|
||||
skill_chip_min,skill_chip_max,cap_value,active,created_at,updated_at
|
||||
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)`)
|
||||
cap_value,active,created_at,updated_at
|
||||
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)`)
|
||||
).run(
|
||||
id,
|
||||
next.name,
|
||||
@@ -158,8 +154,6 @@ try {
|
||||
next.weapon_min,
|
||||
next.weapon_max,
|
||||
next.weapon_reference_level,
|
||||
next.skill_chip_min,
|
||||
next.skill_chip_max,
|
||||
next.cap_value,
|
||||
now,
|
||||
now,
|
||||
|
||||
@@ -2,7 +2,11 @@ import { connect } from '@tursodatabase/database';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { basename, dirname, join, resolve } from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { ISAC_DATA_TABLES, quoteIdentifier } from './isac-data-tables.mjs';
|
||||
import {
|
||||
ISAC_DATA_FORMAT_VERSION,
|
||||
ISAC_DATA_TABLES,
|
||||
quoteIdentifier,
|
||||
} from './isac-data-tables.mjs';
|
||||
|
||||
const databasePath = resolve(
|
||||
process.env.TDR_DATABASE_PATH || './data/shd-build-development.db',
|
||||
@@ -67,7 +71,7 @@ for (const row of [
|
||||
|
||||
const snapshot = {
|
||||
format: 'shd-build-isac-data',
|
||||
version: 2,
|
||||
version: ISAC_DATA_FORMAT_VERSION,
|
||||
createdAt: new Date().toISOString(),
|
||||
sourceDatabase: basename(databasePath),
|
||||
totalRows,
|
||||
|
||||
@@ -3,7 +3,11 @@ import { spawnSync } from 'node:child_process';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { basename, join, resolve } from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { ISAC_DATA_TABLES, quoteIdentifier } from './isac-data-tables.mjs';
|
||||
import {
|
||||
ISAC_DATA_FORMAT_VERSION,
|
||||
ISAC_DATA_TABLES,
|
||||
quoteIdentifier,
|
||||
} from './isac-data-tables.mjs';
|
||||
|
||||
const snapshotArgument = process.argv
|
||||
.slice(2)
|
||||
@@ -22,7 +26,7 @@ const snapshotPath = resolve(snapshotArgument);
|
||||
const snapshot = JSON.parse(await readFile(snapshotPath, 'utf8'));
|
||||
if (
|
||||
snapshot?.format !== 'shd-build-isac-data' ||
|
||||
snapshot?.version !== 2 ||
|
||||
snapshot?.version !== ISAC_DATA_FORMAT_VERSION ||
|
||||
!Array.isArray(snapshot.tables) ||
|
||||
!Array.isArray(snapshot.icons)
|
||||
) {
|
||||
@@ -39,6 +43,233 @@ if (
|
||||
'The ISAC snapshot table manifest is incomplete or out of order.',
|
||||
);
|
||||
}
|
||||
const abilityRows = snapshot.tables.find(
|
||||
(table) => table.name === 'specialization_abilities',
|
||||
).rows;
|
||||
const abilitiesById = new Map(
|
||||
abilityRows.map((row) => [String(row.id ?? ''), row]),
|
||||
);
|
||||
const skillChipRows = snapshot.tables.find(
|
||||
(table) => table.name === 'skill_chip_sets',
|
||||
).rows;
|
||||
const activeSkillChipCounts = new Map();
|
||||
for (const row of skillChipRows) {
|
||||
const abilityId = String(row.specialization_ability_id ?? '');
|
||||
if (!abilityId || !abilitiesById.has(abilityId)) {
|
||||
throw new Error(
|
||||
`The ISAC snapshot contains a Skill Chip definition with a missing ability reference "${abilityId}".`,
|
||||
);
|
||||
}
|
||||
if (Number(row.active) !== 1) continue;
|
||||
const count = (activeSkillChipCounts.get(abilityId) ?? 0) + 1;
|
||||
if (count > 3) {
|
||||
throw new Error(
|
||||
`The ISAC snapshot assigns more than three active Skill Chip definitions to ability "${abilityId}".`,
|
||||
);
|
||||
}
|
||||
activeSkillChipCounts.set(abilityId, count);
|
||||
}
|
||||
const skillChipLinks = snapshot.tables.find(
|
||||
(table) => table.name === 'specialization_ability_skill_chip_links',
|
||||
).rows;
|
||||
const linkedAbilityIds = new Set();
|
||||
for (const row of skillChipLinks) {
|
||||
const abilityId = row.ability_id;
|
||||
const compatibleAbilityId = row.compatible_ability_id;
|
||||
if (
|
||||
typeof abilityId !== 'string' ||
|
||||
!abilityId ||
|
||||
typeof compatibleAbilityId !== 'string' ||
|
||||
!compatibleAbilityId
|
||||
) {
|
||||
throw new Error(
|
||||
'The ISAC snapshot contains a Skill Chip sharing link with malformed ability references.',
|
||||
);
|
||||
}
|
||||
if (abilityId === compatibleAbilityId) {
|
||||
throw new Error(
|
||||
`The ISAC snapshot contains a self-referencing Skill Chip sharing link for ability "${abilityId}".`,
|
||||
);
|
||||
}
|
||||
if (abilityId >= compatibleAbilityId) {
|
||||
throw new Error(
|
||||
`The ISAC snapshot contains a Skill Chip sharing link that is not in canonical ability order: "${abilityId}", "${compatibleAbilityId}".`,
|
||||
);
|
||||
}
|
||||
const ability = abilitiesById.get(abilityId);
|
||||
const compatibleAbility = abilitiesById.get(compatibleAbilityId);
|
||||
if (!ability || !compatibleAbility) {
|
||||
throw new Error(
|
||||
`The ISAC snapshot contains a Skill Chip sharing link with a missing ability reference: "${abilityId}", "${compatibleAbilityId}".`,
|
||||
);
|
||||
}
|
||||
if (Number(ability.active) !== 1 || Number(compatibleAbility.active) !== 1) {
|
||||
throw new Error(
|
||||
`The ISAC snapshot contains a Skill Chip sharing link with an inactive ability: "${abilityId}", "${compatibleAbilityId}".`,
|
||||
);
|
||||
}
|
||||
if (ability.specialization_id !== compatibleAbility.specialization_id) {
|
||||
throw new Error(
|
||||
`The ISAC snapshot contains a cross-specialization Skill Chip sharing link: "${abilityId}", "${compatibleAbilityId}".`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
linkedAbilityIds.has(abilityId) ||
|
||||
linkedAbilityIds.has(compatibleAbilityId)
|
||||
) {
|
||||
throw new Error(
|
||||
'The ISAC snapshot contains more than one Skill Chip sharing link touching an ability.',
|
||||
);
|
||||
}
|
||||
linkedAbilityIds.add(abilityId);
|
||||
linkedAbilityIds.add(compatibleAbilityId);
|
||||
}
|
||||
for (const kind of ['weapon', 'gear']) {
|
||||
const options = snapshot.tables.find(
|
||||
(table) => table.name === `${kind}_mod_secondary_options`,
|
||||
).rows;
|
||||
const values = snapshot.tables.find(
|
||||
(table) => table.name === `${kind}_mod_secondary_quality_values`,
|
||||
).rows;
|
||||
const optionIds = options.map((row) => String(row.stat_attribute_id ?? ''));
|
||||
if (
|
||||
optionIds.some((id) => !id) ||
|
||||
new Set(optionIds).size !== optionIds.length ||
|
||||
(optionIds.length !== 0 &&
|
||||
(optionIds.length < 2 || optionIds.length > 50)) ||
|
||||
values.length !== optionIds.length * 3
|
||||
) {
|
||||
throw new Error(
|
||||
`The ISAC snapshot contains an incomplete shared ${kind} Mod secondary pool.`,
|
||||
);
|
||||
}
|
||||
for (const statId of optionIds) {
|
||||
const profile = values.filter(
|
||||
(row) => String(row.stat_attribute_id) === statId,
|
||||
);
|
||||
if (
|
||||
profile.length !== 3 ||
|
||||
['Specialized', 'Superior', 'High-end'].some(
|
||||
(quality) => !profile.some((row) => row.quality === quality),
|
||||
) ||
|
||||
profile.some((row) => {
|
||||
const endpoints = [
|
||||
row.level_1_min,
|
||||
row.level_1_max,
|
||||
row.level_20_min,
|
||||
row.level_20_max,
|
||||
];
|
||||
return (
|
||||
endpoints.some(
|
||||
(value) => typeof value !== 'number' || !Number.isFinite(value),
|
||||
) ||
|
||||
endpoints[1] < endpoints[0] ||
|
||||
endpoints[2] < endpoints[0] ||
|
||||
endpoints[3] < endpoints[1] ||
|
||||
endpoints[3] < endpoints[2]
|
||||
);
|
||||
})
|
||||
) {
|
||||
throw new Error(
|
||||
`The ISAC snapshot contains an invalid shared ${kind} Mod secondary profile for ${statId}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (
|
||||
values.some(
|
||||
(row) => !optionIds.includes(String(row.stat_attribute_id ?? '')),
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`The ISAC snapshot contains an orphaned shared ${kind} Mod secondary range.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const [profileName, qualities, requiredOptions] of [
|
||||
['primary', ['Standard', 'Specialized', 'Superior', 'High-end'], 4],
|
||||
['secondary', ['Specialized', 'Superior', 'High-end'], null],
|
||||
]) {
|
||||
const options = snapshot.tables.find(
|
||||
(table) => table.name === `skill_chip_${profileName}_options`,
|
||||
).rows;
|
||||
const values = snapshot.tables.find(
|
||||
(table) => table.name === `skill_chip_${profileName}_quality_values`,
|
||||
).rows;
|
||||
const optionIds = options.map((row) => String(row.stat_attribute_id ?? ''));
|
||||
const validCardinality = requiredOptions
|
||||
? optionIds.length === requiredOptions
|
||||
: optionIds.length <= 50;
|
||||
if (
|
||||
!validCardinality ||
|
||||
optionIds.some((id) => !id) ||
|
||||
new Set(optionIds).size !== optionIds.length
|
||||
) {
|
||||
throw new Error(
|
||||
`The ISAC snapshot contains an incomplete shared Skill Chip ${profileName} profile.`,
|
||||
);
|
||||
}
|
||||
if (profileName === 'primary' && optionIds.length) {
|
||||
const attributes = snapshot.tables.find(
|
||||
(table) => table.name === 'stat_attributes',
|
||||
).rows;
|
||||
const expectedIds = attributes
|
||||
.filter(
|
||||
(row) =>
|
||||
Number(row.active) === 1 &&
|
||||
[
|
||||
'Skill Duration',
|
||||
'Skill Radius',
|
||||
'Skill Cooldown Recovery',
|
||||
'Skill Intensity',
|
||||
].includes(String(row.name)),
|
||||
)
|
||||
.map((row) => String(row.id))
|
||||
.sort();
|
||||
if (
|
||||
expectedIds.length !== 4 ||
|
||||
optionIds
|
||||
.slice()
|
||||
.sort()
|
||||
.some((id, index) => id !== expectedIds[index])
|
||||
)
|
||||
throw new Error(
|
||||
'The ISAC snapshot does not use the four fixed Skill Chip primary Attributes.',
|
||||
);
|
||||
}
|
||||
for (const statId of optionIds) {
|
||||
const rows = values.filter(
|
||||
(row) => String(row.stat_attribute_id ?? '') === statId,
|
||||
);
|
||||
if (!rows.length) continue;
|
||||
if (
|
||||
rows.length !== qualities.length ||
|
||||
qualities.some(
|
||||
(quality) => !rows.some((row) => row.quality === quality),
|
||||
) ||
|
||||
rows.some(
|
||||
(row) =>
|
||||
typeof row.min_value !== 'number' ||
|
||||
!Number.isFinite(row.min_value) ||
|
||||
typeof row.max_value !== 'number' ||
|
||||
!Number.isFinite(row.max_value) ||
|
||||
row.min_value < 0 ||
|
||||
row.max_value < row.min_value,
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`The ISAC snapshot contains an invalid shared Skill Chip ${profileName} profile for ${statId}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (
|
||||
values.some(
|
||||
(row) => !optionIds.includes(String(row.stat_attribute_id ?? '')),
|
||||
)
|
||||
)
|
||||
throw new Error(
|
||||
`The ISAC snapshot contains an orphaned shared Skill Chip ${profileName} range.`,
|
||||
);
|
||||
}
|
||||
for (const icon of snapshot.icons) {
|
||||
if (
|
||||
typeof icon.storageKey !== 'string' ||
|
||||
|
||||
@@ -32,7 +32,9 @@ export const ISAC_DATA_TABLES = [
|
||||
'skill_chip_sets',
|
||||
'skill_chip_two_piece_bonuses',
|
||||
'skill_chip_primary_options',
|
||||
'skill_chip_primary_quality_values',
|
||||
'skill_chip_secondary_options',
|
||||
'skill_chip_secondary_quality_values',
|
||||
'skill_chip_talents',
|
||||
'skill_chip_talent_effects',
|
||||
'weapon_mods',
|
||||
@@ -55,6 +57,7 @@ export const ISAC_DATA_TABLES = [
|
||||
'specialization_combat_effects',
|
||||
'specialization_focuses',
|
||||
'specialization_abilities',
|
||||
'specialization_ability_skill_chip_links',
|
||||
'specialization_ability_attacks',
|
||||
'specialization_ability_effects',
|
||||
'specialization_focus_abilities',
|
||||
@@ -73,5 +76,7 @@ export const ISAC_DATA_TABLES = [
|
||||
'managed_images',
|
||||
];
|
||||
|
||||
export const ISAC_DATA_FORMAT_VERSION = 5;
|
||||
|
||||
export const quoteIdentifier = (identifier) =>
|
||||
`"${String(identifier).replaceAll('"', '""')}"`;
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { connect, type Database } from '@tursodatabase/database';
|
||||
import { isFullAdministrator } from '../src/lib/server/auth/bootstrap';
|
||||
|
||||
const run = promisify(execFile);
|
||||
let directory: string | undefined;
|
||||
let database: Database | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
await database?.close();
|
||||
if (directory) await rm(directory, { recursive: true, force: true });
|
||||
database = undefined;
|
||||
directory = undefined;
|
||||
});
|
||||
|
||||
describe('legacy administrator migration', () => {
|
||||
it('moves legacy administrator rows to role-admin and is repeatable', async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), 'shd-admin-migration-'));
|
||||
const databasePath = join(directory, 'database.db');
|
||||
database = await connect(databasePath);
|
||||
await database.exec(await readFile('database/schema.sql', 'utf8'));
|
||||
await (
|
||||
await database.prepare(
|
||||
"INSERT INTO users(id,username,role,created_at) VALUES('legacy-admin','legacy','admin',?)",
|
||||
)
|
||||
).run(new Date().toISOString());
|
||||
await database.close();
|
||||
database = undefined;
|
||||
|
||||
const environment = {
|
||||
...process.env,
|
||||
TDR_DATABASE_PATH: databasePath,
|
||||
SKIP_DEFAULT_USERS: 'true',
|
||||
};
|
||||
await run(process.execPath, ['scripts/db-migrate.mjs'], {
|
||||
cwd: process.cwd(),
|
||||
env: environment,
|
||||
});
|
||||
database = await connect(databasePath);
|
||||
let user = await (
|
||||
await database.prepare('SELECT role FROM users WHERE id=?')
|
||||
).get('legacy-admin');
|
||||
let relationship = await (
|
||||
await database.prepare(
|
||||
'SELECT role_id FROM user_roles WHERE user_id=? AND role_id=?',
|
||||
)
|
||||
).get('legacy-admin', 'role-admin');
|
||||
expect(user.role).toBe('user');
|
||||
expect(relationship.role_id).toBe('role-admin');
|
||||
|
||||
await database.close();
|
||||
database = undefined;
|
||||
await run(process.execPath, ['scripts/db-migrate.mjs'], {
|
||||
cwd: process.cwd(),
|
||||
env: environment,
|
||||
});
|
||||
database = await connect(databasePath);
|
||||
user = await (
|
||||
await database.prepare('SELECT role FROM users WHERE id=?')
|
||||
).get('legacy-admin');
|
||||
relationship = await (
|
||||
await database.prepare(
|
||||
'SELECT COUNT(*) AS count FROM user_roles WHERE user_id=? AND role_id=?',
|
||||
)
|
||||
).get('legacy-admin', 'role-admin');
|
||||
expect(user.role).toBe('user');
|
||||
expect(Number(relationship.count)).toBe(1);
|
||||
expect(await isFullAdministrator(database, 'legacy-admin')).toBe(true);
|
||||
await (
|
||||
await database.prepare(
|
||||
'DELETE FROM user_roles WHERE user_id=? AND role_id=?',
|
||||
)
|
||||
).run('legacy-admin', 'role-admin');
|
||||
expect(await isFullAdministrator(database, 'legacy-admin')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -23,7 +23,7 @@ const images = [];
|
||||
for (const row of rows) {
|
||||
if (
|
||||
typeof row.storage_key !== 'string' ||
|
||||
!/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i.test(
|
||||
!/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node|weapon_mod_slot)-[0-9a-f-]{36}\.webp$/i.test(
|
||||
row.storage_key,
|
||||
)
|
||||
) {
|
||||
|
||||
@@ -14,6 +14,19 @@ if (!bundleArgument || !process.argv.includes('--confirm-merge')) {
|
||||
process.exit(2);
|
||||
}
|
||||
const updateExisting = process.argv.includes('--update-existing');
|
||||
const gameDataRenderSourceTypes = new Set([
|
||||
'weapon_render',
|
||||
'gear_render',
|
||||
'weapon_mod_render',
|
||||
'gear_mod_render',
|
||||
'talent_render',
|
||||
'brand_set_render',
|
||||
'os_protocol_render',
|
||||
'skill_chip_render',
|
||||
'specialization_render',
|
||||
'specialization_ability_render',
|
||||
'specialization_node_render',
|
||||
]);
|
||||
const bundlePath = resolve(bundleArgument);
|
||||
const bundle = JSON.parse(await readFile(bundlePath, 'utf8'));
|
||||
if (
|
||||
@@ -30,21 +43,21 @@ for (const image of bundle.images) {
|
||||
typeof image?.row?.id !== 'string' ||
|
||||
!/^[0-9a-f-]{36}$/i.test(image.row.id) ||
|
||||
typeof image.row.storage_key !== 'string' ||
|
||||
!/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i.test(
|
||||
!/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node|weapon_mod_slot)-[0-9a-f-]{36}\.webp$/i.test(
|
||||
image.row.storage_key,
|
||||
) ||
|
||||
typeof image.contentBase64 !== 'string'
|
||||
) {
|
||||
throw new Error('The bundle contains an invalid managed image.');
|
||||
}
|
||||
const isItemRender = ['weapon_render', 'gear_render'].includes(
|
||||
const isGameDataRender = gameDataRenderSourceTypes.has(
|
||||
image.row.source_entity_type,
|
||||
);
|
||||
if (
|
||||
image.row.source_entity_type &&
|
||||
(typeof image.row.source_entity_id !== 'string' ||
|
||||
!/^[0-9a-f-]{36}$/i.test(image.row.source_entity_id) ||
|
||||
(isItemRender
|
||||
(isGameDataRender
|
||||
? !image.row.storage_key.startsWith('library-')
|
||||
: !image.row.storage_key.startsWith(
|
||||
`${image.row.source_entity_type}-`,
|
||||
|
||||
@@ -5,6 +5,7 @@ const APP_REQUIRED = [
|
||||
'APP_ORIGIN',
|
||||
'ORIGIN',
|
||||
'PASSKEY_RP_ID',
|
||||
'MFA_TOTP_ENCRYPTION_KEYS',
|
||||
'SCHEDULER_TOKEN',
|
||||
'TDR_DATABASE_PATH',
|
||||
'TDR_AVATAR_STORAGE_PATH',
|
||||
@@ -48,6 +49,7 @@ export function validateRuntimeEnvironment(mode, environment) {
|
||||
|
||||
requireValues(APP_REQUIRED);
|
||||
validateSchedulerToken(get('SCHEDULER_TOKEN'), errors);
|
||||
validateMfaTotpEncryptionKeys(get('MFA_TOTP_ENCRYPTION_KEYS'), errors);
|
||||
|
||||
const appOrigin = validateOrigin('APP_ORIGIN', get('APP_ORIGIN'), errors);
|
||||
const adapterOrigin = validateOrigin('ORIGIN', get('ORIGIN'), errors);
|
||||
@@ -230,3 +232,60 @@ function validateSchedulerToken(value, errors) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the app-only TOTP encryption keyring without including configured
|
||||
* values in an error. The first entry is the active write key.
|
||||
*
|
||||
* @param {string} value
|
||||
* @param {string[]} errors
|
||||
*/
|
||||
function validateMfaTotpEncryptionKeys(value, errors) {
|
||||
if (!value) return;
|
||||
const entries = value.split(',').map((entry) => entry.trim());
|
||||
if (
|
||||
entries.length < 1 ||
|
||||
entries.length > 3 ||
|
||||
entries.some((entry) => !entry)
|
||||
) {
|
||||
errors.push(
|
||||
'MFA_TOTP_ENCRYPTION_KEYS must contain 1 through 3 key entries.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const ids = new Set();
|
||||
const keys = new Set();
|
||||
for (const entry of entries) {
|
||||
const separator = entry.indexOf(':');
|
||||
const keyId = separator > 0 ? entry.slice(0, separator) : '';
|
||||
const encodedKey = separator > 0 ? entry.slice(separator + 1) : '';
|
||||
if (
|
||||
!/^[A-Za-z0-9_-]{1,64}$/.test(keyId) ||
|
||||
!/^[A-Za-z0-9_-]+$/.test(encodedKey) ||
|
||||
encodedKey.length !== 43
|
||||
) {
|
||||
errors.push(
|
||||
'MFA_TOTP_ENCRYPTION_KEYS entries must use key-id and canonical 32-byte Base64URL values.',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let decoded;
|
||||
try {
|
||||
decoded = Buffer.from(encodedKey, 'base64url');
|
||||
} catch {
|
||||
decoded = Buffer.alloc(0);
|
||||
}
|
||||
if (decoded.length !== 32 || decoded.toString('base64url') !== encodedKey) {
|
||||
errors.push(
|
||||
'MFA_TOTP_ENCRYPTION_KEYS entries must use key-id and canonical 32-byte Base64URL values.',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (ids.has(keyId) || keys.has(encodedKey)) {
|
||||
errors.push('MFA_TOTP_ENCRYPTION_KEYS must not repeat key IDs or keys.');
|
||||
}
|
||||
ids.add(keyId);
|
||||
keys.add(encodedKey);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ const validAppEnvironment = {
|
||||
APP_ORIGIN: 'https://stage.shd.build',
|
||||
ORIGIN: 'https://stage.shd.build',
|
||||
PASSKEY_RP_ID: 'shd.build',
|
||||
MFA_TOTP_ENCRYPTION_KEYS:
|
||||
'primary-2026:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
||||
SCHEDULER_TOKEN: 'scheduler-secret-with-32-characters',
|
||||
TDR_DATABASE_PATH: '/app/data/shd-build-staging.db',
|
||||
TDR_AVATAR_STORAGE_PATH: '/app/data/avatars',
|
||||
@@ -21,6 +23,7 @@ describe('validateRuntimeEnvironment', () => {
|
||||
'APP_ORIGIN is required.',
|
||||
'ORIGIN is required.',
|
||||
'PASSKEY_RP_ID is required.',
|
||||
'MFA_TOTP_ENCRYPTION_KEYS is required.',
|
||||
'SCHEDULER_TOKEN is required.',
|
||||
'TDR_DATABASE_PATH is required.',
|
||||
'TDR_AVATAR_STORAGE_PATH is required.',
|
||||
@@ -158,4 +161,24 @@ describe('validateRuntimeEnvironment', () => {
|
||||
validateRuntimeEnvironment('worker' as 'app', validAppEnvironment),
|
||||
).toThrow('Unsupported runtime mode: worker');
|
||||
});
|
||||
|
||||
it('validates the MFA encryption keyring without echoing secrets', () => {
|
||||
expect(
|
||||
validateRuntimeEnvironment('app', {
|
||||
...validAppEnvironment,
|
||||
MFA_TOTP_ENCRYPTION_KEYS:
|
||||
'one:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA,one:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
||||
}),
|
||||
).toEqual(['MFA_TOTP_ENCRYPTION_KEYS must not repeat key IDs or keys.']);
|
||||
|
||||
const errors = validateRuntimeEnvironment('app', {
|
||||
...validAppEnvironment,
|
||||
MFA_TOTP_ENCRYPTION_KEYS:
|
||||
'primary:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
|
||||
});
|
||||
expect(errors).toEqual([
|
||||
'MFA_TOTP_ENCRYPTION_KEYS entries must use key-id and canonical 32-byte Base64URL values.',
|
||||
]);
|
||||
expect(errors.join('\n')).not.toContain('primary:');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import { validateRuntimeEnvironment } from './runtime-environment.js';
|
||||
|
||||
const APP_SECRET_NAMES = new Set([
|
||||
'APP_ORIGIN',
|
||||
'MFA_TOTP_ENCRYPTION_KEYS',
|
||||
'DISCORD_CLIENT_ID',
|
||||
'DISCORD_CLIENT_SECRET',
|
||||
'DISCORD_REDIRECT_URI',
|
||||
|
||||
+847
-24
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,15 @@ import { json, type Handle, type HandleServerError } from '@sveltejs/kit';
|
||||
import { stringify } from 'devalue';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import {
|
||||
hasActiveTotpFactor,
|
||||
isFullAdministrator,
|
||||
} from '$lib/server/auth/bootstrap';
|
||||
import {
|
||||
createAuthenticationTransaction,
|
||||
getAuthenticationTransaction,
|
||||
} from '$lib/server/auth/authentication-transactions';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import {
|
||||
getSiteAccessBlock,
|
||||
getSiteAccessSettings,
|
||||
@@ -49,6 +58,62 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const isIsacRoute =
|
||||
event.url.pathname === '/isac' || event.url.pathname.startsWith('/isac/');
|
||||
if (sessionUser && isIsacRoute) {
|
||||
const database = await getDatabase();
|
||||
if (await isFullAdministrator(database, sessionUser.id)) {
|
||||
const hasFactor = await hasActiveTotpFactor(database, sessionUser.id);
|
||||
const mutation = !['GET', 'HEAD'].includes(event.request.method);
|
||||
if (!hasFactor) {
|
||||
if (mutation)
|
||||
return json(
|
||||
{ error: 'Administrator MFA enrollment is required.' },
|
||||
{ status: 403, headers: { 'cache-control': 'no-store' } },
|
||||
);
|
||||
return new Response(null, {
|
||||
status: 303,
|
||||
headers: {
|
||||
location: '/account/security?required=admin',
|
||||
'cache-control': 'no-store',
|
||||
'referrer-policy': 'no-referrer',
|
||||
},
|
||||
});
|
||||
}
|
||||
if (sessionUser.aal !== 2) {
|
||||
if (mutation)
|
||||
return json(
|
||||
{ error: 'AAL2 authentication is required.' },
|
||||
{ status: 403, headers: { 'cache-control': 'no-store' } },
|
||||
);
|
||||
const returnPath = `${event.url.pathname}${event.url.search}`;
|
||||
const existing = await getAuthenticationTransaction(
|
||||
event.cookies,
|
||||
database,
|
||||
);
|
||||
if (
|
||||
!existing ||
|
||||
existing.userId !== sessionUser.id ||
|
||||
existing.kind !== 'step_up'
|
||||
) {
|
||||
await createAuthenticationTransaction(event.cookies, database, {
|
||||
userId: sessionUser.id,
|
||||
kind: 'step_up',
|
||||
returnPath,
|
||||
});
|
||||
}
|
||||
return new Response(null, {
|
||||
status: 303,
|
||||
headers: {
|
||||
location: '/auth/two-factor',
|
||||
'cache-control': 'no-store',
|
||||
'referrer-policy': 'no-referrer',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (event.request.method === 'POST') {
|
||||
const match = event.url.pathname.match(/^\/isac\/([^/]+)$/);
|
||||
const module = match?.[1] as DataModule | undefined;
|
||||
|
||||
@@ -91,6 +91,172 @@ describe('article documents', () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts and canonicalizes metadata emitted by the Tiptap editor', () => {
|
||||
const parsed = parseArticleDocument({
|
||||
version: 1,
|
||||
document: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Read more',
|
||||
marks: [
|
||||
{
|
||||
type: 'link',
|
||||
attrs: {
|
||||
href: 'https://shd.build/news',
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer nofollow',
|
||||
class: null,
|
||||
title: 'News archive',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'orderedList',
|
||||
attrs: { start: 1, type: null },
|
||||
content: [
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'First item' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed?.document.document.content).toEqual([
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Read more',
|
||||
marks: [
|
||||
{
|
||||
type: 'link',
|
||||
attrs: {
|
||||
href: 'https://shd.build/news',
|
||||
title: 'News archive',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'orderedList',
|
||||
content: [
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'First item' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(parsed?.plainText).toContain('News archive');
|
||||
});
|
||||
|
||||
it('preserves supported ordered-list numbering', () => {
|
||||
const parsed = parseArticleDocument({
|
||||
version: 1,
|
||||
document: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'orderedList',
|
||||
attrs: { start: 2, type: 'A' },
|
||||
content: [
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [{ type: 'paragraph' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed?.document.document.content?.[0]).toMatchObject({
|
||||
type: 'orderedList',
|
||||
attrs: { start: 2, type: 'A' },
|
||||
});
|
||||
expect(parseArticleDocument(parsed?.document)?.document).toEqual(
|
||||
parsed?.document,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects unknown link metadata and non-default ordered lists', () => {
|
||||
const documentWith = (content: unknown[]) => ({
|
||||
version: 1,
|
||||
document: { type: 'doc', content },
|
||||
});
|
||||
|
||||
expect(
|
||||
parseArticleDocument(
|
||||
documentWith([
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'unsafe metadata',
|
||||
marks: [
|
||||
{
|
||||
type: 'link',
|
||||
attrs: {
|
||||
href: 'https://shd.build',
|
||||
onclick: 'alert(1)',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseArticleDocument(
|
||||
documentWith([
|
||||
{
|
||||
type: 'orderedList',
|
||||
attrs: { start: 0, type: 'decimal' },
|
||||
content: [],
|
||||
},
|
||||
]),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseArticleDocument(
|
||||
documentWith([
|
||||
{
|
||||
type: 'orderedList',
|
||||
attrs: { start: 1, type: 1 },
|
||||
content: [],
|
||||
},
|
||||
]),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects duplicate embed keys and unknown envelope fields', () => {
|
||||
const embed = {
|
||||
type: 'buildEmbed',
|
||||
|
||||
@@ -10,7 +10,7 @@ const EMBED_KEY_PATTERN = /^[a-z0-9][a-z0-9-]{7,63}$/;
|
||||
|
||||
export type ArticleMark = {
|
||||
type: 'bold' | 'italic' | 'code' | 'link';
|
||||
attrs?: { href: string };
|
||||
attrs?: { href: string; title?: string };
|
||||
};
|
||||
|
||||
export type ArticleNode = {
|
||||
@@ -53,7 +53,17 @@ const safeLink = (value: unknown) => {
|
||||
}
|
||||
};
|
||||
|
||||
function parseMarks(value: unknown): ArticleMark[] | null {
|
||||
const nullableBoundedString = (value: unknown, maximum: number) =>
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
(typeof value === 'string' &&
|
||||
value.length <= maximum &&
|
||||
!/\p{Cc}/u.test(value));
|
||||
|
||||
function parseMarks(
|
||||
value: unknown,
|
||||
onLinkTitle: (title: string) => void,
|
||||
): ArticleMark[] | null {
|
||||
if (value === undefined) return [];
|
||||
if (!Array.isArray(value) || value.length > 4) return null;
|
||||
const seen = new Set<string>();
|
||||
@@ -77,10 +87,31 @@ function parseMarks(value: unknown): ArticleMark[] | null {
|
||||
!isRecord(candidate.attrs)
|
||||
)
|
||||
return null;
|
||||
if (!exactKeys(candidate.attrs, ['href'])) return null;
|
||||
if (
|
||||
!exactKeys(candidate.attrs, [
|
||||
'href',
|
||||
'target',
|
||||
'rel',
|
||||
'class',
|
||||
'title',
|
||||
]) ||
|
||||
!nullableBoundedString(candidate.attrs.target, 100) ||
|
||||
!nullableBoundedString(candidate.attrs.rel, 500) ||
|
||||
!nullableBoundedString(candidate.attrs.class, 500) ||
|
||||
!nullableBoundedString(candidate.attrs.title, 500)
|
||||
)
|
||||
return null;
|
||||
const href = safeLink(candidate.attrs.href);
|
||||
if (!href) return null;
|
||||
marks.push({ type: 'link', attrs: { href } });
|
||||
const title =
|
||||
typeof candidate.attrs.title === 'string'
|
||||
? candidate.attrs.title.normalize('NFKC').trim()
|
||||
: '';
|
||||
if (title) onLinkTitle(title);
|
||||
marks.push({
|
||||
type: 'link',
|
||||
attrs: { href, ...(title ? { title } : {}) },
|
||||
});
|
||||
}
|
||||
return marks;
|
||||
}
|
||||
@@ -134,7 +165,9 @@ export function parseArticleDocument(value: unknown): {
|
||||
if (/\p{Cc}/u.test(text.replace(/[\n\t]/g, ''))) return null;
|
||||
textLength += [...text].length;
|
||||
if (textLength > MAX_ARTICLE_TEXT_CHARACTERS) return null;
|
||||
const marks = parseMarks(candidate.marks);
|
||||
const marks = parseMarks(candidate.marks, (title) =>
|
||||
textParts.push(title),
|
||||
);
|
||||
if (!marks) return null;
|
||||
textParts.push(text);
|
||||
return marks.length ? { type, text, marks } : { type, text };
|
||||
@@ -211,6 +244,31 @@ export function parseArticleDocument(value: unknown): {
|
||||
)
|
||||
return null;
|
||||
attrs = { level: Number(candidate.attrs.level) };
|
||||
} else if (type === 'orderedList' && candidate.attrs !== undefined) {
|
||||
if (
|
||||
!isRecord(candidate.attrs) ||
|
||||
!exactKeys(candidate.attrs, ['start', 'type'])
|
||||
)
|
||||
return null;
|
||||
const start = candidate.attrs.start ?? 1;
|
||||
const listType = candidate.attrs.type;
|
||||
if (
|
||||
!Number.isInteger(start) ||
|
||||
Number(start) < 1 ||
|
||||
Number(start) > 1_000_000 ||
|
||||
(listType !== null &&
|
||||
listType !== undefined &&
|
||||
(typeof listType !== 'string' ||
|
||||
!['1', 'a', 'A', 'i', 'I'].includes(listType)))
|
||||
)
|
||||
return null;
|
||||
const canonicalType =
|
||||
typeof listType === 'string' && listType !== '1' ? listType : undefined;
|
||||
attrs = {
|
||||
...(Number(start) === 1 ? {} : { start: Number(start) }),
|
||||
...(canonicalType ? { type: canonicalType } : {}),
|
||||
};
|
||||
if (!Object.keys(attrs).length) attrs = undefined;
|
||||
} else if (candidate.attrs !== undefined) {
|
||||
if (!isRecord(candidate.attrs) || Object.keys(candidate.attrs).length)
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { buildShareUrl, copyBuildLink, shareBuild } from './build-sharing';
|
||||
|
||||
describe('Build sharing', () => {
|
||||
it('constructs Build URLs from slugs and preserves absolute URLs', () => {
|
||||
expect(buildShareUrl('field-medic', 'https://shd.build')).toBe(
|
||||
'https://shd.build/builds/field-medic',
|
||||
);
|
||||
expect(buildShareUrl('build name', 'https://shd.build/ignored')).toBe(
|
||||
'https://shd.build/builds/build%20name',
|
||||
);
|
||||
expect(buildShareUrl('https://stage.shd.build/builds/test')).toBe(
|
||||
'https://stage.shd.build/builds/test',
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the native share API', async () => {
|
||||
const share = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
shareBuild(
|
||||
{ share },
|
||||
{ title: 'Field Medic', url: 'https://shd.build/builds/field-medic' },
|
||||
),
|
||||
).resolves.toEqual({ state: 'shared' });
|
||||
expect(share).toHaveBeenCalledWith({
|
||||
title: 'Field Medic',
|
||||
text: 'View Field Medic on SHD.build.',
|
||||
url: 'https://shd.build/builds/field-medic',
|
||||
});
|
||||
});
|
||||
|
||||
it('treats AbortError as cancellation', async () => {
|
||||
const share = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new DOMException('Cancelled', 'AbortError'));
|
||||
|
||||
await expect(
|
||||
shareBuild(
|
||||
{ share },
|
||||
{ title: 'Build', url: 'https://shd.build/builds/a' },
|
||||
),
|
||||
).resolves.toEqual({ state: 'cancelled' });
|
||||
});
|
||||
|
||||
it('reports unsupported and failed native sharing', async () => {
|
||||
await expect(
|
||||
shareBuild({}, { title: 'Build', url: 'https://shd.build/builds/a' }),
|
||||
).resolves.toEqual({ state: 'unsupported' });
|
||||
|
||||
const error = new Error('Share failed');
|
||||
await expect(
|
||||
shareBuild(
|
||||
{ share: vi.fn().mockRejectedValue(error) },
|
||||
{ title: 'Build', url: 'https://shd.build/builds/a' },
|
||||
),
|
||||
).resolves.toEqual({ state: 'failed', error });
|
||||
});
|
||||
|
||||
it('copies the Build URL to the clipboard', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
copyBuildLink({ clipboard: { writeText } }, 'https://shd.build/builds/a'),
|
||||
).resolves.toEqual({ state: 'copied' });
|
||||
expect(writeText).toHaveBeenCalledWith('https://shd.build/builds/a');
|
||||
});
|
||||
|
||||
it('reports unsupported and failed clipboard access', async () => {
|
||||
await expect(
|
||||
copyBuildLink({}, 'https://shd.build/builds/a'),
|
||||
).resolves.toEqual({
|
||||
state: 'unsupported',
|
||||
});
|
||||
|
||||
const error = new Error('Clipboard denied');
|
||||
await expect(
|
||||
copyBuildLink(
|
||||
{ clipboard: { writeText: vi.fn().mockRejectedValue(error) } },
|
||||
'https://shd.build/builds/a',
|
||||
),
|
||||
).resolves.toEqual({ state: 'failed', error });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
export type BuildShareNavigator = {
|
||||
share?: (data: ShareData) => Promise<void>;
|
||||
clipboard?: {
|
||||
writeText: (text: string) => Promise<void>;
|
||||
};
|
||||
};
|
||||
|
||||
export type BuildShareResult =
|
||||
| { state: 'shared' }
|
||||
| { state: 'cancelled' }
|
||||
| { state: 'unsupported' }
|
||||
| { state: 'failed'; error: unknown };
|
||||
|
||||
export type BuildCopyResult =
|
||||
| { state: 'copied' }
|
||||
| { state: 'unsupported' }
|
||||
| { state: 'failed'; error: unknown };
|
||||
|
||||
export function buildShareUrl(slugOrUrl: string, origin?: string): string {
|
||||
const value = slugOrUrl.trim();
|
||||
if (!value) throw new Error('A Build slug or URL is required.');
|
||||
|
||||
if (/^https?:\/\//i.test(value)) return new URL(value).toString();
|
||||
|
||||
const base = origin ?? globalThis.location?.origin;
|
||||
if (!base) throw new Error('An origin is required to construct a Build URL.');
|
||||
|
||||
const slug = value.replace(/^\/+|\/+$/g, '');
|
||||
return new URL(`/builds/${encodeURIComponent(slug)}`, base).toString();
|
||||
}
|
||||
|
||||
export async function shareBuild(
|
||||
navigatorApi: BuildShareNavigator,
|
||||
input: { title: string; url: string },
|
||||
): Promise<BuildShareResult> {
|
||||
if (typeof navigatorApi.share !== 'function') return { state: 'unsupported' };
|
||||
|
||||
try {
|
||||
await navigatorApi.share({
|
||||
title: input.title,
|
||||
text: `View ${input.title} on SHD.build.`,
|
||||
url: input.url,
|
||||
});
|
||||
return { state: 'shared' };
|
||||
} catch (error) {
|
||||
if (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'name' in error &&
|
||||
error.name === 'AbortError'
|
||||
) {
|
||||
return { state: 'cancelled' };
|
||||
}
|
||||
return { state: 'failed', error };
|
||||
}
|
||||
}
|
||||
|
||||
export async function copyBuildLink(
|
||||
navigatorApi: BuildShareNavigator,
|
||||
url: string,
|
||||
): Promise<BuildCopyResult> {
|
||||
if (typeof navigatorApi.clipboard?.writeText !== 'function') {
|
||||
return { state: 'unsupported' };
|
||||
}
|
||||
|
||||
try {
|
||||
await navigatorApi.clipboard.writeText(url);
|
||||
return { state: 'copied' };
|
||||
} catch (error) {
|
||||
return { state: 'failed', error };
|
||||
}
|
||||
}
|
||||
@@ -259,7 +259,7 @@ describe('loadout mechanic timeline', () => {
|
||||
).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('falls back to a documented Tier I mechanic when equipment Tier II is absent', () => {
|
||||
it('falls back to a documented Tier I mechanic when exotic Tier II is absent', () => {
|
||||
const persistedRule = {
|
||||
rule: {
|
||||
id: 'tier-i-only',
|
||||
@@ -327,6 +327,7 @@ describe('loadout mechanic timeline', () => {
|
||||
const adapted = equippedConditionalProgram({
|
||||
key: 'weapon:0:exotic-talent',
|
||||
tier: 2,
|
||||
isExotic: true,
|
||||
rule: persistedRule,
|
||||
});
|
||||
|
||||
|
||||
@@ -25,20 +25,20 @@ import {
|
||||
type SkillCalculationRules,
|
||||
type SkillModifierWindow,
|
||||
} from '$lib/calculations/skill';
|
||||
import { gearSlots, scaled, type LoadoutDraft } from '$lib/builder/loadout';
|
||||
import { gearSlots, type LoadoutDraft } from '$lib/builder/loadout';
|
||||
import {
|
||||
applyAttributeValue,
|
||||
catalogAttribute as attribute,
|
||||
selectedFocus,
|
||||
selectedNodeRanks,
|
||||
summarizeLoadout,
|
||||
type LoadoutCatalog,
|
||||
type LoadoutSummary,
|
||||
} from '$lib/builder/loadout-summary';
|
||||
import type { WeaponCalculationResult } from '$lib/components/builder/WeaponCalculationResult.svelte';
|
||||
|
||||
type Catalog = {
|
||||
attributes: any[];
|
||||
gear: any[];
|
||||
gearAugments: any[];
|
||||
brands: any[];
|
||||
weapons: any[];
|
||||
talents: any[];
|
||||
chipSets: any[];
|
||||
protocols: any[];
|
||||
specializations: any[];
|
||||
calculation: {
|
||||
type Catalog = LoadoutCatalog & {
|
||||
calculation?: {
|
||||
revision: any | null;
|
||||
stages: any[];
|
||||
profiles: any[];
|
||||
@@ -46,380 +46,16 @@ type Catalog = {
|
||||
};
|
||||
};
|
||||
|
||||
export type LoadoutSummary = {
|
||||
stats: Array<{ id: string; value: number; sourceCount: number }>;
|
||||
activeBonuses: any[];
|
||||
equippedTalents: any[];
|
||||
};
|
||||
export { summarizeLoadout } from '$lib/builder/loadout-summary';
|
||||
export type { LoadoutSummary } from '$lib/builder/loadout-summary';
|
||||
|
||||
const coreKeys = ['firepower', 'toughness', 'engineering'] as const;
|
||||
|
||||
function selectedFocus(draft: LoadoutDraft, catalog: Catalog) {
|
||||
return catalog.specializations
|
||||
.flatMap((specialization) =>
|
||||
specialization.focuses.map((focus: any) => ({
|
||||
...focus,
|
||||
specialization,
|
||||
})),
|
||||
)
|
||||
.find((focus: any) => focus.id === draft.specialization.focusId);
|
||||
}
|
||||
|
||||
function selectedNodeRanks(draft: LoadoutDraft, catalog: Catalog) {
|
||||
const focus = selectedFocus(draft, catalog);
|
||||
return (
|
||||
focus?.nodes.flatMap((node: any) => {
|
||||
const selectedRank = Number(draft.specialization.nodeRanks[node.id] ?? 0);
|
||||
return selectedRank
|
||||
? [
|
||||
{
|
||||
node,
|
||||
rank: node.ranks.find(
|
||||
(entry: any) => Number(entry.rank) === selectedRank,
|
||||
),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
function attribute(catalog: Catalog, id: string) {
|
||||
return catalog.attributes.find((item) => item.id === id);
|
||||
}
|
||||
|
||||
function applyAttributeValue(
|
||||
catalog: Catalog,
|
||||
current: number,
|
||||
statId: string,
|
||||
incoming: number,
|
||||
) {
|
||||
const stat = attribute(catalog, statId);
|
||||
const combined =
|
||||
stat?.calculation_type === 'Multiplicative' && stat?.unit === '%'
|
||||
? ((1 + current / 100) * (1 + incoming / 100) - 1) * 100
|
||||
: current + incoming;
|
||||
return stat?.cap_value === null || stat?.cap_value === undefined
|
||||
? combined
|
||||
: Math.min(combined, Number(stat.cap_value));
|
||||
}
|
||||
|
||||
function unitValue(catalog: Catalog, value: unknown, statId: string) {
|
||||
const numeric = Number(value);
|
||||
const rendered = Number.isInteger(numeric)
|
||||
? String(numeric)
|
||||
: numeric.toFixed(2).replace(/\.?0+$/, '');
|
||||
const unit = attribute(catalog, statId)?.unit ?? '';
|
||||
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`;
|
||||
}
|
||||
|
||||
function talentTier(catalog: Catalog, talentId: string, tier: number) {
|
||||
const item = catalog.talents.find((entry) => entry.id === talentId);
|
||||
return (
|
||||
item?.tiers.find((entry: any) => entry.tier === (tier >= 2 ? 'II' : 'I')) ??
|
||||
item?.tiers[0]
|
||||
);
|
||||
}
|
||||
|
||||
function talentDescription(catalog: Catalog, talentId: string, tier: number) {
|
||||
const item = catalog.talents.find((entry) => entry.id === talentId);
|
||||
const configured = talentTier(catalog, talentId, tier);
|
||||
if (!item || !configured) return '';
|
||||
const template = String(item.template || item.name);
|
||||
return template.replace(/\{(\d+)\}/g, (placeholder, raw) => {
|
||||
const effect = configured.effects[Number(raw) - 1];
|
||||
return effect
|
||||
? unitValue(catalog, effect.value, effect.stat_attribute_id)
|
||||
: placeholder;
|
||||
});
|
||||
}
|
||||
|
||||
export function summarizeLoadout(
|
||||
draft: LoadoutDraft,
|
||||
catalog: Catalog,
|
||||
): LoadoutSummary {
|
||||
const values = new Map<string, number>();
|
||||
const sources = new Map<string, Set<string>>();
|
||||
const add = (statId: string, value: unknown, source: string) => {
|
||||
if (!statId || !Number.isFinite(Number(value))) return;
|
||||
const stat = attribute(catalog, statId);
|
||||
const current = values.get(statId) ?? 0;
|
||||
const incoming = Number(value);
|
||||
const combined =
|
||||
stat?.calculation_type === 'Multiplicative' && stat?.unit === '%'
|
||||
? ((1 + current / 100) * (1 + incoming / 100) - 1) * 100
|
||||
: current + incoming;
|
||||
values.set(
|
||||
statId,
|
||||
stat?.cap_value === null || stat?.cap_value === undefined
|
||||
? combined
|
||||
: Math.min(combined, Number(stat.cap_value)),
|
||||
);
|
||||
if (!sources.has(statId)) sources.set(statId, new Set());
|
||||
sources.get(statId)!.add(source);
|
||||
const calculationCatalog = (catalog: Catalog) =>
|
||||
catalog.calculation ?? {
|
||||
revision: null,
|
||||
stages: [],
|
||||
profiles: [],
|
||||
skillProfiles: [],
|
||||
};
|
||||
const byName = (name: string) =>
|
||||
catalog.attributes.find(
|
||||
(item) => item.name.toLocaleLowerCase() === name.toLocaleLowerCase(),
|
||||
)?.id ?? '';
|
||||
const brandCounts = new Map<string, number>();
|
||||
const activeBonuses: any[] = [];
|
||||
const equippedTalents: any[] = [];
|
||||
|
||||
for (const slot of gearSlots) {
|
||||
const selection = draft.gear[slot];
|
||||
const item = catalog.gear.find((entry) => entry.id === selection.itemId);
|
||||
if (!item) continue;
|
||||
const enabledAugments = catalog.gearAugments.filter(
|
||||
(entry) =>
|
||||
entry.gear_slot === slot &&
|
||||
selection.augmentSlots.includes(Number(entry.augment_slot)),
|
||||
);
|
||||
const basePercentage = enabledAugments
|
||||
.filter((entry) => entry.effect_type === 'Base Percentage')
|
||||
.reduce((total, entry) => total + Number(entry.value), 0);
|
||||
add(
|
||||
byName('Health'),
|
||||
selection.health * (1 + basePercentage / 100),
|
||||
`${slot} base`,
|
||||
);
|
||||
if (item.base_armor_min !== null)
|
||||
add(
|
||||
byName('Armor'),
|
||||
selection.armor * (1 + basePercentage / 100),
|
||||
`${slot} base`,
|
||||
);
|
||||
for (const roll of selection.rolls)
|
||||
add(roll.statId, roll.value, `${slot} roll`);
|
||||
for (const key of coreKeys)
|
||||
add(byName(key), selection[key], `${slot} core`);
|
||||
if (selection.brandId)
|
||||
brandCounts.set(
|
||||
selection.brandId,
|
||||
(brandCounts.get(selection.brandId) ?? 0) + 1,
|
||||
);
|
||||
for (const augment of enabledAugments.filter(
|
||||
(entry) => entry.effect_type === 'Custom',
|
||||
)) {
|
||||
add(
|
||||
augment.stat_attribute_id,
|
||||
augment.value,
|
||||
`${slot} augment ${augment.augment_slot}`,
|
||||
);
|
||||
}
|
||||
if (selection.talentId) {
|
||||
equippedTalents.push({
|
||||
key: `gear:${slot}:${selection.talentId}`,
|
||||
id: selection.talentId,
|
||||
rule:
|
||||
catalog.talents.find((entry) => entry.id === selection.talentId)
|
||||
?.rule ?? null,
|
||||
tier: Number(item.tier),
|
||||
source: slot,
|
||||
description: talentDescription(
|
||||
catalog,
|
||||
selection.talentId,
|
||||
Number(item.tier),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const [brandId, count] of brandCounts) {
|
||||
const set = catalog.brands.find((entry) => entry.id === brandId);
|
||||
for (const effect of set?.effects.filter(
|
||||
(entry: any) => Number(entry.pieces_required) <= count,
|
||||
) ?? []) {
|
||||
add(
|
||||
effect.stat_attribute_id,
|
||||
effect.value,
|
||||
`${set.name} ${effect.pieces_required}-piece`,
|
||||
);
|
||||
activeBonuses.push({
|
||||
name: set.name,
|
||||
pieces: effect.pieces_required,
|
||||
count,
|
||||
statId: effect.stat_attribute_id,
|
||||
value: effect.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (let index = 0; index < 2; index += 1) {
|
||||
const selection = draft.weapons[index];
|
||||
const item = catalog.weapons.find((entry) => entry.id === selection.itemId);
|
||||
if (!item) continue;
|
||||
for (const name of ['Firepower', 'Toughness', 'Engineering'] as const) {
|
||||
add(
|
||||
byName(name),
|
||||
selection[name.toLowerCase() as 'firepower'],
|
||||
`${index ? 'Secondary' : 'Primary'} weapon`,
|
||||
);
|
||||
}
|
||||
for (const bonus of item.baseBonuses ?? [])
|
||||
add(bonus.stat_attribute_id, bonus.value, `${item.name} base bonus`);
|
||||
for (const roll of selection.rolls)
|
||||
add(
|
||||
roll.statId,
|
||||
roll.value,
|
||||
`${index ? 'Secondary' : 'Primary'} weapon roll`,
|
||||
);
|
||||
for (const talentId of [
|
||||
selection.talent1Id,
|
||||
selection.talent2Id,
|
||||
item.exotic_talent_id,
|
||||
].filter(Boolean)) {
|
||||
equippedTalents.push({
|
||||
key: `weapon:${index}:${talentId}`,
|
||||
id: talentId,
|
||||
rule:
|
||||
catalog.talents.find((entry) => entry.id === talentId)?.rule ?? null,
|
||||
tier: selection.tier,
|
||||
source: index ? 'Secondary weapon' : 'Primary weapon',
|
||||
description: talentDescription(catalog, talentId, selection.tier),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const chipGroups = new Map<
|
||||
string,
|
||||
{ count: number; highEnd: number; item: any }
|
||||
>();
|
||||
for (let index = 0; index < 9; index += 1) {
|
||||
const selection = draft.chips[index];
|
||||
const item = catalog.chipSets.find((entry) => entry.id === selection.setId);
|
||||
if (!item) continue;
|
||||
add(
|
||||
selection.primary.statId,
|
||||
selection.primary.value,
|
||||
`Skill Chip ${index + 1} primary`,
|
||||
);
|
||||
for (const roll of selection.secondary)
|
||||
add(roll.statId, roll.value, `Skill Chip ${index + 1} secondary`);
|
||||
const group = chipGroups.get(item.id) ?? { count: 0, highEnd: 0, item };
|
||||
group.count += 1;
|
||||
if (selection.quality === 'High-end') group.highEnd += 1;
|
||||
chipGroups.set(item.id, group);
|
||||
}
|
||||
for (const group of chipGroups.values()) {
|
||||
if (group.count >= 2 && group.item.twoPiece) {
|
||||
const value = scaled(
|
||||
group.item.twoPiece.min_value,
|
||||
group.item.twoPiece.max_value,
|
||||
group.highEnd,
|
||||
3,
|
||||
);
|
||||
add(
|
||||
group.item.twoPiece.stat_attribute_id,
|
||||
value,
|
||||
`${group.item.name} 2-piece`,
|
||||
);
|
||||
activeBonuses.push({
|
||||
name: group.item.name,
|
||||
pieces: 2,
|
||||
count: group.count,
|
||||
statId: group.item.twoPiece.stat_attribute_id,
|
||||
value,
|
||||
});
|
||||
}
|
||||
if (group.count === 3 && group.item.talent) {
|
||||
equippedTalents.push({
|
||||
key: `chips:${group.item.id}`,
|
||||
id: '',
|
||||
custom: group.item.talent,
|
||||
rule: group.item.talent.rule ?? null,
|
||||
tier: group.highEnd,
|
||||
abilityId: group.item.specialization_ability_id ?? null,
|
||||
source: '3 Skill Chips',
|
||||
description: group.item.talent.description_template.replace(
|
||||
/\{(\d+)\}/g,
|
||||
(placeholder: string, raw: string) => {
|
||||
const effect = group.item.talent.effects[Number(raw) - 1];
|
||||
return effect
|
||||
? unitValue(
|
||||
catalog,
|
||||
scaled(effect.min_value, effect.max_value, group.highEnd, 3),
|
||||
effect.stat_attribute_id,
|
||||
)
|
||||
: placeholder;
|
||||
},
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const { node, rank } of selectedNodeRanks(draft, catalog)) {
|
||||
for (const effect of rank?.effects ?? []) {
|
||||
if (effect.effect_type === 'Attribute')
|
||||
add(
|
||||
effect.stat_attribute_id,
|
||||
effect.value,
|
||||
`${node.name} rank ${rank.rank}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const protocol = catalog.protocols.find(
|
||||
(entry) => entry.id === draft.protocol.itemId,
|
||||
);
|
||||
if (protocol) {
|
||||
if (protocol.primaryEffect) {
|
||||
add(
|
||||
protocol.primaryEffect.stat_attribute_id,
|
||||
Number(protocol.primaryEffect.base_value) *
|
||||
(1 + (draft.protocol.level - 1) / 49),
|
||||
`${protocol.name} primary`,
|
||||
);
|
||||
}
|
||||
for (const effect of protocol.secondaryEffects ?? []) {
|
||||
add(effect.stat_attribute_id, effect.value, `${protocol.name} secondary`);
|
||||
}
|
||||
if (protocol.talent) {
|
||||
equippedTalents.push({
|
||||
key: `protocol:${protocol.id}`,
|
||||
id: '',
|
||||
custom: protocol.talent,
|
||||
rule: protocol.talent.rule ?? null,
|
||||
tier: draft.protocol.talentUpgrades,
|
||||
source: 'OS Protocol',
|
||||
description: protocol.talent.description_template.replace(
|
||||
/\{(\d+)\}/g,
|
||||
(placeholder: string, raw: string) => {
|
||||
const effect = protocol.talent.effects[Number(raw) - 1];
|
||||
return effect
|
||||
? unitValue(
|
||||
catalog,
|
||||
Number(effect.base_value) *
|
||||
(1 + draft.protocol.talentUpgrades / 5),
|
||||
effect.stat_attribute_id,
|
||||
)
|
||||
: placeholder;
|
||||
},
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
stats: [...values]
|
||||
.map(([id, value]) => ({
|
||||
id,
|
||||
value,
|
||||
sourceCount: sources.get(id)?.size ?? 0,
|
||||
}))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
(attribute(catalog, left.id)?.category ?? '').localeCompare(
|
||||
attribute(catalog, right.id)?.category ?? '',
|
||||
) ||
|
||||
(attribute(catalog, left.id)?.name ?? '').localeCompare(
|
||||
attribute(catalog, right.id)?.name ?? '',
|
||||
),
|
||||
),
|
||||
activeBonuses,
|
||||
equippedTalents,
|
||||
};
|
||||
}
|
||||
|
||||
function calculationStage(
|
||||
catalog: Catalog,
|
||||
@@ -441,7 +77,7 @@ function calculationStage(
|
||||
) {
|
||||
return stageKey as CalculationStage;
|
||||
}
|
||||
const stage = catalog.calculation.stages.find(
|
||||
const stage = calculationCatalog(catalog).stages.find(
|
||||
(item) => item.stage_key === stageKey,
|
||||
);
|
||||
if (!stage) return null;
|
||||
@@ -462,8 +98,8 @@ function calculationBindings(
|
||||
catalog: Catalog,
|
||||
weapon: any,
|
||||
): AttributeBinding[] {
|
||||
return catalog.calculation.profiles
|
||||
.filter(
|
||||
return calculationCatalog(catalog)
|
||||
.profiles.filter(
|
||||
(item) =>
|
||||
item.weapon_category === '*' ||
|
||||
item.weapon_category === weapon?.category,
|
||||
@@ -647,7 +283,7 @@ export function equippedConditionalProgram(
|
||||
: 'base';
|
||||
return (
|
||||
conditionalProgram(equipped.rule, variant) ??
|
||||
(equipmentTalent && variant === 'II'
|
||||
(equipmentTalent && equipped.isExotic && variant === 'II'
|
||||
? conditionalProgram(equipped.rule, 'I')
|
||||
: null)
|
||||
);
|
||||
@@ -1211,25 +847,27 @@ function skillCalculationBindings(catalog: Catalog): SkillAttributeBinding[] {
|
||||
'cooldown_recovery',
|
||||
'duration',
|
||||
]);
|
||||
return (catalog.calculation.skillProfiles ?? []).flatMap((profile: any) => {
|
||||
const stage =
|
||||
profile.stage_key === 'engineering'
|
||||
? 'skill_damage'
|
||||
: String(profile.stage_key);
|
||||
return supportedStages.has(stage)
|
||||
? [
|
||||
{
|
||||
attributeId: String(profile.stat_attribute_id),
|
||||
stage: stage as SkillAttributeBinding['stage'],
|
||||
operator:
|
||||
profile.operator === 'multiply'
|
||||
? ('multiply' as const)
|
||||
: ('add' as const),
|
||||
weight: Number(profile.weight ?? 1),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
return (calculationCatalog(catalog).skillProfiles ?? []).flatMap(
|
||||
(profile: any) => {
|
||||
const stage =
|
||||
profile.stage_key === 'engineering'
|
||||
? 'skill_damage'
|
||||
: String(profile.stage_key);
|
||||
return supportedStages.has(stage)
|
||||
? [
|
||||
{
|
||||
attributeId: String(profile.stat_attribute_id),
|
||||
stage: stage as SkillAttributeBinding['stage'],
|
||||
operator:
|
||||
profile.operator === 'multiply'
|
||||
? ('multiply' as const)
|
||||
: ('add' as const),
|
||||
weight: Number(profile.weight ?? 1),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function loadoutSkillAbilities(
|
||||
@@ -1348,7 +986,7 @@ function loadoutSkillDeploymentEvents(
|
||||
Math.max(1, Number(draft.combatProfile.encounterSeconds)),
|
||||
{
|
||||
revisionId: String(
|
||||
catalog.calculation.revision?.id ?? 'mechanic-timeline',
|
||||
calculationCatalog(catalog).revision?.id ?? 'mechanic-timeline',
|
||||
),
|
||||
bindings,
|
||||
},
|
||||
@@ -1539,9 +1177,9 @@ export function evaluateLoadoutCombat(
|
||||
);
|
||||
const required = (weaponIndex: number) => {
|
||||
const missing: string[] = [];
|
||||
if (!catalog.calculation.revision)
|
||||
if (!calculationCatalog(catalog).revision)
|
||||
missing.push('An active ISAC calculation revision');
|
||||
else if (!catalog.calculation.profiles.length)
|
||||
else if (!calculationCatalog(catalog).profiles.length)
|
||||
missing.push('Published attribute calculation profiles');
|
||||
if (!draft.weapons[weaponIndex].itemId)
|
||||
missing.push(`${weaponIndex ? 'Secondary' : 'Primary'} weapon`);
|
||||
@@ -1570,8 +1208,8 @@ export function evaluateLoadoutCombat(
|
||||
weaponLabel: weaponIndex ? 'SECONDARY WEAPON' : 'PRIMARY WEAPON',
|
||||
status: 'insufficient',
|
||||
missing,
|
||||
revisionLabel: catalog.calculation.revision
|
||||
? `RULES R${catalog.calculation.revision.version}`
|
||||
revisionLabel: calculationCatalog(catalog).revision
|
||||
? `RULES R${calculationCatalog(catalog).revision.version}`
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
@@ -1660,8 +1298,8 @@ export function evaluateLoadoutCombat(
|
||||
directDamageTotal += outcome.appliedValue;
|
||||
}
|
||||
}
|
||||
const calculationStages = catalog.calculation.stages
|
||||
.map((stage) => calculationStage(catalog, String(stage.stage_key)))
|
||||
const calculationStages = calculationCatalog(catalog)
|
||||
.stages.map((stage) => calculationStage(catalog, String(stage.stage_key)))
|
||||
.filter((stage): stage is CalculationStage => stage !== null);
|
||||
const result = calculateCombat({
|
||||
weapon: {
|
||||
@@ -1693,7 +1331,7 @@ export function evaluateLoadoutCombat(
|
||||
? `Calculation profile for ${attribute(catalog, item.slice(8))?.name ?? item.slice(8)}`
|
||||
: item.replaceAll('.', ' '),
|
||||
),
|
||||
revisionLabel: `RULES R${catalog.calculation.revision.version}`,
|
||||
revisionLabel: `RULES R${calculationCatalog(catalog).revision.version}`,
|
||||
};
|
||||
}
|
||||
const timeline = buildWeaponMechanicTimeline(
|
||||
@@ -1754,7 +1392,7 @@ export function evaluateLoadoutCombat(
|
||||
mode: source.state.mode,
|
||||
...mechanicSummary(source.evaluation),
|
||||
})),
|
||||
revisionLabel: `RULES R${catalog.calculation.revision.version}`,
|
||||
revisionLabel: `RULES R${calculationCatalog(catalog).revision.version}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1764,7 +1402,7 @@ export function evaluateLoadoutSkillCombat(
|
||||
catalog: Catalog,
|
||||
summary = summarizeLoadout(draft, catalog),
|
||||
): SkillCalculation {
|
||||
const revisionId = String(catalog.calculation.revision?.id ?? '');
|
||||
const revisionId = String(calculationCatalog(catalog).revision?.id ?? '');
|
||||
const bindings = skillCalculationBindings(catalog);
|
||||
const boundIds = new Set(bindings.map((binding) => binding.attributeId));
|
||||
const attributes = Object.fromEntries(
|
||||
|
||||
@@ -0,0 +1,686 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createDraft } from './loadout.js';
|
||||
import { summarizeLoadout, type LoadoutCatalog } from './loadout-summary.js';
|
||||
|
||||
const attribute = (
|
||||
id: string,
|
||||
name: string,
|
||||
options: {
|
||||
unit?: string;
|
||||
calculationType?: string;
|
||||
cap?: number | null;
|
||||
} = {},
|
||||
) => ({
|
||||
id,
|
||||
name,
|
||||
category: 'Combat',
|
||||
unit: options.unit ?? '',
|
||||
calculation_type: options.calculationType ?? 'Additive',
|
||||
cap_value: options.cap ?? null,
|
||||
});
|
||||
|
||||
const catalog = (overrides: Partial<LoadoutCatalog> = {}): LoadoutCatalog => ({
|
||||
attributes: [],
|
||||
gear: [],
|
||||
gearAugments: [],
|
||||
brands: [],
|
||||
weapons: [],
|
||||
talents: [],
|
||||
chipSets: [],
|
||||
protocols: [],
|
||||
specializations: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const stat = (summary: ReturnType<typeof summarizeLoadout>, id: string) =>
|
||||
summary.stats.find((entry) => entry.id === id);
|
||||
|
||||
describe('summarizeLoadout', () => {
|
||||
it('returns an empty deterministic summary for a partial draft', () => {
|
||||
expect(summarizeLoadout(createDraft(), catalog())).toEqual({
|
||||
stats: [],
|
||||
abilityStats: [],
|
||||
activeBonuses: [],
|
||||
equippedTalents: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores saved rolls that do not reference a known Attribute', () => {
|
||||
const draft = createDraft();
|
||||
draft.weapons[0].itemId = 'weapon';
|
||||
draft.weapons[0].rolls[0] = { statId: 'unknown', value: 999 };
|
||||
|
||||
const summary = summarizeLoadout(
|
||||
draft,
|
||||
catalog({
|
||||
weapons: [
|
||||
{
|
||||
id: 'weapon',
|
||||
name: 'Test Weapon',
|
||||
exotic_talent_id: null,
|
||||
baseBonuses: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(summary.stats).toEqual([]);
|
||||
});
|
||||
|
||||
it('combines additive and multiplicative Attributes, applies caps, and counts distinct sources', () => {
|
||||
const draft = createDraft();
|
||||
draft.weapons[0].itemId = 'weapon';
|
||||
draft.weapons[0].rolls = [
|
||||
{ statId: 'additive', value: 5 },
|
||||
{ statId: 'multiplicative', value: 20 },
|
||||
];
|
||||
|
||||
const summary = summarizeLoadout(
|
||||
draft,
|
||||
catalog({
|
||||
attributes: [
|
||||
attribute('additive', 'Additive'),
|
||||
attribute('multiplicative', 'Multiplicative', {
|
||||
unit: '%',
|
||||
calculationType: 'Multiplicative',
|
||||
}),
|
||||
attribute('capped', 'Capped', { cap: 25 }),
|
||||
],
|
||||
weapons: [
|
||||
{
|
||||
id: 'weapon',
|
||||
name: 'Test Weapon',
|
||||
exotic_talent_id: null,
|
||||
baseBonuses: [
|
||||
{ stat_attribute_id: 'additive', value: 10 },
|
||||
{ stat_attribute_id: 'multiplicative', value: 10 },
|
||||
{ stat_attribute_id: 'capped', value: 30 },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(stat(summary, 'additive')).toEqual({
|
||||
id: 'additive',
|
||||
value: 15,
|
||||
sourceCount: 2,
|
||||
});
|
||||
expect(stat(summary, 'multiplicative')?.value).toBeCloseTo(32);
|
||||
expect(stat(summary, 'multiplicative')?.sourceCount).toBe(2);
|
||||
expect(stat(summary, 'capped')).toEqual({
|
||||
id: 'capped',
|
||||
value: 25,
|
||||
sourceCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('activates every earned set bonus and records it as a stat source', () => {
|
||||
const draft = createDraft();
|
||||
for (const slot of ['Holster', 'Gloves'] as const) {
|
||||
draft.gear[slot].itemId = `${slot}-gear`;
|
||||
draft.gear[slot].brandId = 'brand';
|
||||
}
|
||||
|
||||
const summary = summarizeLoadout(
|
||||
draft,
|
||||
catalog({
|
||||
attributes: [attribute('set-stat', 'Set Stat', { unit: '%' })],
|
||||
gear: [
|
||||
{ id: 'Holster-gear', tier: 0, base_armor_min: null },
|
||||
{ id: 'Gloves-gear', tier: 0, base_armor_min: null },
|
||||
],
|
||||
brands: [
|
||||
{
|
||||
id: 'brand',
|
||||
name: 'Determinist',
|
||||
effects: [
|
||||
{
|
||||
pieces_required: 1,
|
||||
stat_attribute_id: 'set-stat',
|
||||
value: 5,
|
||||
},
|
||||
{
|
||||
pieces_required: 2,
|
||||
stat_attribute_id: 'set-stat',
|
||||
value: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(stat(summary, 'set-stat')).toEqual({
|
||||
id: 'set-stat',
|
||||
value: 15,
|
||||
sourceCount: 2,
|
||||
});
|
||||
expect(summary.activeBonuses).toEqual([
|
||||
expect.objectContaining({ pieces: 1, count: 2, value: 5 }),
|
||||
expect.objectContaining({ pieces: 2, count: 2, value: 10 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('interpolates Skill Chip bonuses and Talent descriptions from High-end count', () => {
|
||||
const draft = createDraft();
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
draft.chips[index].setId = 'chip-set';
|
||||
draft.chips[index].quality = index === 0 ? 'High-end' : 'Superior';
|
||||
}
|
||||
|
||||
const summary = summarizeLoadout(
|
||||
draft,
|
||||
catalog({
|
||||
attributes: [attribute('skill-damage', 'Skill Damage', { unit: '%' })],
|
||||
chipSets: [
|
||||
{
|
||||
id: 'chip-set',
|
||||
name: 'Signal',
|
||||
specialization_ability_id: 'ability',
|
||||
twoPiece: {
|
||||
stat_attribute_id: 'skill-damage',
|
||||
min_value: 10,
|
||||
max_value: 40,
|
||||
},
|
||||
talent: {
|
||||
name: 'Signal Talent',
|
||||
description_template: 'Gain {1} Skill Damage.',
|
||||
effects: [
|
||||
{
|
||||
stat_attribute_id: 'skill-damage',
|
||||
min_value: 15,
|
||||
max_value: 45,
|
||||
},
|
||||
],
|
||||
rule: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(stat(summary, 'skill-damage')).toEqual({
|
||||
id: 'skill-damage',
|
||||
value: 20,
|
||||
sourceCount: 1,
|
||||
});
|
||||
expect(summary.equippedTalents).toEqual([
|
||||
expect.objectContaining({
|
||||
key: 'chips:chip-set:ability',
|
||||
tier: 1,
|
||||
abilityId: 'ability',
|
||||
description: 'Gain 25% Skill Damage.',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps Skill Chip primaries ability-scoped while adding secondaries to Agent stats', () => {
|
||||
const draft = createDraft();
|
||||
draft.specialization.focusId = 'focus-1';
|
||||
draft.chips[0] = {
|
||||
setId: 'chip-set',
|
||||
quality: 'Superior',
|
||||
primary: { statId: 'skill-duration', value: 12 },
|
||||
secondary: [
|
||||
{ statId: 'agent-stat', value: 4 },
|
||||
{ statId: 'other-secondary', value: 6 },
|
||||
],
|
||||
};
|
||||
|
||||
const summary = summarizeLoadout(
|
||||
draft,
|
||||
catalog({
|
||||
attributes: [
|
||||
attribute('skill-duration', 'Skill Duration', { unit: '%' }),
|
||||
attribute('agent-stat', 'Agent Stat', { unit: '%' }),
|
||||
attribute('other-secondary', 'Other Secondary', { unit: '%' }),
|
||||
],
|
||||
chipSets: [
|
||||
{
|
||||
id: 'chip-set',
|
||||
name: 'Signal',
|
||||
specialization_ability_id: 'ability-2',
|
||||
compatibleAbilityIds: ['ability-2', 'ability-1'],
|
||||
twoPiece: null,
|
||||
talent: null,
|
||||
},
|
||||
],
|
||||
specializations: [
|
||||
{
|
||||
id: 'specialization-1',
|
||||
focuses: [
|
||||
{
|
||||
id: 'focus-1',
|
||||
abilities: [{ id: 'ability-1' }],
|
||||
nodes: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(stat(summary, 'skill-duration')).toBeUndefined();
|
||||
expect(stat(summary, 'agent-stat')?.value).toBe(4);
|
||||
expect(stat(summary, 'other-secondary')?.value).toBe(6);
|
||||
expect(summary.abilityStats).toEqual([
|
||||
{
|
||||
abilityId: 'ability-1',
|
||||
stats: [
|
||||
{
|
||||
id: 'skill-duration',
|
||||
value: 12,
|
||||
sourceCount: 1,
|
||||
sources: [
|
||||
{
|
||||
source: 'Skill Chip 1 primary',
|
||||
chipIndex: 0,
|
||||
chipSetId: 'chip-set',
|
||||
value: 12,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores a shared Skill Chip after its ability link is removed', () => {
|
||||
const draft = createDraft();
|
||||
draft.specialization.focusId = 'focus-1';
|
||||
draft.chips[0] = {
|
||||
setId: 'chip-set',
|
||||
quality: 'Standard',
|
||||
primary: { statId: 'skill-duration', value: 12 },
|
||||
secondary: [],
|
||||
};
|
||||
|
||||
const summary = summarizeLoadout(
|
||||
draft,
|
||||
catalog({
|
||||
attributes: [
|
||||
attribute('skill-duration', 'Skill Duration', { unit: '%' }),
|
||||
],
|
||||
chipSets: [
|
||||
{
|
||||
id: 'chip-set',
|
||||
name: 'Signal',
|
||||
specialization_ability_id: 'ability-2',
|
||||
compatibleAbilityIds: ['ability-2'],
|
||||
twoPiece: null,
|
||||
talent: null,
|
||||
},
|
||||
],
|
||||
specializations: [
|
||||
{
|
||||
id: 'specialization-1',
|
||||
focuses: [
|
||||
{
|
||||
id: 'focus-1',
|
||||
abilities: [{ id: 'ability-1' }],
|
||||
nodes: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(summary.abilityStats).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[0, 10],
|
||||
[1, 20],
|
||||
[2, 30],
|
||||
[3, 40],
|
||||
])(
|
||||
'scales a three-chip 2-piece bonus with %i High-end chips',
|
||||
(highEndCount, expectedValue) => {
|
||||
const draft = createDraft();
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
draft.chips[index].setId = 'chip-set';
|
||||
draft.chips[index].quality =
|
||||
index < highEndCount ? 'High-end' : 'Standard';
|
||||
}
|
||||
|
||||
const summary = summarizeLoadout(
|
||||
draft,
|
||||
catalog({
|
||||
attributes: [
|
||||
attribute('skill-damage', 'Skill Damage', { unit: '%' }),
|
||||
],
|
||||
chipSets: [
|
||||
{
|
||||
id: 'chip-set',
|
||||
name: 'Signal',
|
||||
specialization_ability_id: 'ability',
|
||||
twoPiece: {
|
||||
stat_attribute_id: 'skill-damage',
|
||||
min_value: 10,
|
||||
max_value: 40,
|
||||
},
|
||||
talent: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(stat(summary, 'skill-damage')?.value).toBe(expectedValue);
|
||||
expect(summary.activeBonuses[0]).toMatchObject({
|
||||
count: 3,
|
||||
value: expectedValue,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('scales Protocol primary stats and Talent descriptions independently', () => {
|
||||
const draft = createDraft();
|
||||
draft.protocol = { itemId: 'protocol', level: 50, talentUpgrades: 5 };
|
||||
|
||||
const summary = summarizeLoadout(
|
||||
draft,
|
||||
catalog({
|
||||
attributes: [
|
||||
attribute('health', 'Health'),
|
||||
attribute('healing', 'Healing', { unit: '%' }),
|
||||
],
|
||||
protocols: [
|
||||
{
|
||||
id: 'protocol',
|
||||
name: 'Survival',
|
||||
primaryEffect: {
|
||||
stat_attribute_id: 'health',
|
||||
base_value: 100,
|
||||
},
|
||||
secondaryEffects: [{ stat_attribute_id: 'healing', value: 5 }],
|
||||
talent: {
|
||||
name: 'Recovery',
|
||||
description_template: 'Restore {1}.',
|
||||
effects: [{ stat_attribute_id: 'healing', base_value: 2.5 }],
|
||||
rule: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(stat(summary, 'health')?.value).toBe(200);
|
||||
expect(stat(summary, 'healing')?.value).toBe(5);
|
||||
expect(summary.equippedTalents[0]).toMatchObject({
|
||||
key: 'protocol:protocol',
|
||||
tier: 5,
|
||||
description: 'Restore 5%.',
|
||||
});
|
||||
});
|
||||
|
||||
it('applies static specialization rank effects but excludes combat mechanic outcomes', () => {
|
||||
const draft = createDraft();
|
||||
draft.specialization.focusId = 'focus';
|
||||
draft.specialization.nodeRanks.node = 2;
|
||||
|
||||
const summary = summarizeLoadout(
|
||||
draft,
|
||||
catalog({
|
||||
attributes: [
|
||||
attribute('static', 'Static'),
|
||||
attribute('mechanic', 'Mechanic'),
|
||||
],
|
||||
specializations: [
|
||||
{
|
||||
id: 'specialization',
|
||||
focuses: [
|
||||
{
|
||||
id: 'focus',
|
||||
nodes: [
|
||||
{
|
||||
id: 'node',
|
||||
name: 'Steady Hand',
|
||||
ranks: [
|
||||
{
|
||||
rank: 2,
|
||||
effects: [
|
||||
{
|
||||
effect_type: 'Attribute',
|
||||
stat_attribute_id: 'static',
|
||||
value: 12,
|
||||
},
|
||||
],
|
||||
combatEffects: [
|
||||
{
|
||||
rule: {
|
||||
clauses: [
|
||||
{
|
||||
outcomes: [
|
||||
{
|
||||
kind: 'attribute_modifier',
|
||||
attributeId: 'mechanic',
|
||||
value: 99,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(stat(summary, 'static')).toEqual({
|
||||
id: 'static',
|
||||
value: 12,
|
||||
sourceCount: 1,
|
||||
});
|
||||
expect(stat(summary, 'mechanic')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses a gear Talent tier independently from the gear tier', () => {
|
||||
const draft = createDraft();
|
||||
draft.gear.Holster.itemId = 'gear';
|
||||
draft.gear.Holster.talentId = 'talent';
|
||||
Object.assign(draft.gear.Holster, { talentTier: 2 });
|
||||
|
||||
const summary = summarizeLoadout(
|
||||
draft,
|
||||
catalog({
|
||||
attributes: [attribute('bonus', 'Bonus', { unit: '%' })],
|
||||
gear: [{ id: 'gear', tier: 0, base_armor_min: null }],
|
||||
talents: [
|
||||
{
|
||||
id: 'talent',
|
||||
name: 'Reinforced',
|
||||
template: 'Gain {1}.',
|
||||
tiers: [
|
||||
{
|
||||
tier: 'I',
|
||||
effects: [{ stat_attribute_id: 'bonus', value: 10 }],
|
||||
},
|
||||
{
|
||||
tier: 'II',
|
||||
effects: [{ stat_attribute_id: 'bonus', value: 20 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(summary.equippedTalents).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'talent',
|
||||
tier: 2,
|
||||
tierLabel: 'TIER II',
|
||||
description: 'Gain 20%.',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses each weapon Talent tier independently without applying mechanic outcomes to stats', () => {
|
||||
const draft = createDraft();
|
||||
draft.weapons[0].itemId = 'weapon';
|
||||
draft.weapons[0].tier = 2;
|
||||
draft.weapons[0].talent1Id = 'talent-one';
|
||||
draft.weapons[0].talent2Id = 'talent-two';
|
||||
Object.assign(draft.weapons[0], {
|
||||
talent1Tier: 1,
|
||||
talent2Tier: 2,
|
||||
exoticTalentTier: 1,
|
||||
});
|
||||
|
||||
const rule = {
|
||||
clauses: [
|
||||
{
|
||||
outcomes: [
|
||||
{
|
||||
kind: 'attribute_modifier',
|
||||
attributeId: 'talent-stat',
|
||||
value: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const summary = summarizeLoadout(
|
||||
draft,
|
||||
catalog({
|
||||
attributes: [
|
||||
attribute('talent-stat', 'Talent Stat', { unit: '%' }),
|
||||
attribute('shots', 'Shots', { unit: 'Shots' }),
|
||||
],
|
||||
weapons: [
|
||||
{
|
||||
id: 'weapon',
|
||||
name: 'Test Weapon',
|
||||
exotic_talent_id: 'exotic-talent',
|
||||
baseBonuses: [],
|
||||
},
|
||||
],
|
||||
talents: [
|
||||
{
|
||||
id: 'talent-one',
|
||||
name: 'Measured',
|
||||
template: 'Gain {1} for {2}.',
|
||||
tiers: [
|
||||
{
|
||||
tier: 'I',
|
||||
effects: [
|
||||
{ stat_attribute_id: 'talent-stat', value: 10 },
|
||||
{ stat_attribute_id: 'shots', value: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
tier: 'II',
|
||||
effects: [
|
||||
{ stat_attribute_id: 'talent-stat', value: 20 },
|
||||
{ stat_attribute_id: 'shots', value: 3 },
|
||||
],
|
||||
},
|
||||
],
|
||||
rule,
|
||||
},
|
||||
{
|
||||
id: 'talent-two',
|
||||
name: 'Calibrated',
|
||||
template: 'Gain {1}.',
|
||||
tiers: [
|
||||
{
|
||||
tier: 'I',
|
||||
effects: [{ stat_attribute_id: 'talent-stat', value: 30 }],
|
||||
},
|
||||
{
|
||||
tier: 'II',
|
||||
effects: [{ stat_attribute_id: 'talent-stat', value: 40 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'exotic-talent',
|
||||
name: 'Exotic Talent',
|
||||
template: 'Gain {1}.',
|
||||
tiers: [
|
||||
{
|
||||
tier: 'I',
|
||||
effects: [{ stat_attribute_id: 'talent-stat', value: 50 }],
|
||||
},
|
||||
{
|
||||
tier: 'II',
|
||||
effects: [{ stat_attribute_id: 'talent-stat', value: 60 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(summary.equippedTalents).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'talent-one',
|
||||
tier: 1,
|
||||
tierLabel: 'TIER I',
|
||||
description: 'Gain 10% for 2 Shots.',
|
||||
rule,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'talent-two',
|
||||
tier: 2,
|
||||
tierLabel: 'TIER II',
|
||||
description: 'Gain 40%.',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'exotic-talent',
|
||||
tier: 1,
|
||||
tierLabel: 'TIER I',
|
||||
description: 'Gain 50%.',
|
||||
}),
|
||||
]);
|
||||
expect(stat(summary, 'talent-stat')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses the authored tier description when a Talent has no template row', () => {
|
||||
const draft = createDraft();
|
||||
draft.weapons[0].itemId = 'weapon';
|
||||
draft.weapons[0].talent1Id = 'talent';
|
||||
|
||||
const summary = summarizeLoadout(
|
||||
draft,
|
||||
catalog({
|
||||
attributes: [attribute('bonus', 'Bonus', { unit: '%' })],
|
||||
weapons: [
|
||||
{
|
||||
id: 'weapon',
|
||||
name: 'Test Weapon',
|
||||
exotic_talent_id: null,
|
||||
baseBonuses: [],
|
||||
},
|
||||
],
|
||||
talents: [
|
||||
{
|
||||
id: 'talent',
|
||||
name: 'Measured',
|
||||
template: '',
|
||||
tiers: [
|
||||
{
|
||||
tier: 'I',
|
||||
description: 'Gain {1} while active.',
|
||||
effects: [{ stat_attribute_id: 'bonus', value: 10 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(summary.equippedTalents[0]?.description).toBe(
|
||||
'Gain 10% while active.',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,551 @@
|
||||
import {
|
||||
gearSlots,
|
||||
scaled,
|
||||
type LoadoutDraft,
|
||||
type TalentTier,
|
||||
} from '$lib/builder/loadout';
|
||||
|
||||
export type LoadoutCatalog = {
|
||||
attributes: any[];
|
||||
gear: any[];
|
||||
gearAugments: any[];
|
||||
brands: any[];
|
||||
weapons: any[];
|
||||
talents: any[];
|
||||
skillChipProfile?: {
|
||||
primaryOptions: any[];
|
||||
secondaryOptions: any[];
|
||||
};
|
||||
chipSets: any[];
|
||||
protocols: any[];
|
||||
specializations: any[];
|
||||
};
|
||||
|
||||
export type LoadoutSummary = {
|
||||
stats: Array<{ id: string; value: number; sourceCount: number }>;
|
||||
abilityStats: Array<{
|
||||
abilityId: string;
|
||||
stats: Array<{
|
||||
id: string;
|
||||
value: number;
|
||||
sourceCount: number;
|
||||
sources: Array<{
|
||||
source: string;
|
||||
chipIndex: number;
|
||||
chipSetId: string;
|
||||
value: number;
|
||||
}>;
|
||||
}>;
|
||||
}>;
|
||||
activeBonuses: any[];
|
||||
equippedTalents: any[];
|
||||
};
|
||||
|
||||
const coreKeys = ['firepower', 'toughness', 'engineering'] as const;
|
||||
|
||||
export function selectedFocus(draft: LoadoutDraft, catalog: LoadoutCatalog) {
|
||||
return catalog.specializations
|
||||
.flatMap((specialization) =>
|
||||
specialization.focuses.map((focus: any) => ({
|
||||
...focus,
|
||||
specialization,
|
||||
})),
|
||||
)
|
||||
.find((focus: any) => focus.id === draft.specialization.focusId);
|
||||
}
|
||||
|
||||
export function selectedNodeRanks(
|
||||
draft: LoadoutDraft,
|
||||
catalog: LoadoutCatalog,
|
||||
) {
|
||||
const focus = selectedFocus(draft, catalog);
|
||||
return (
|
||||
focus?.nodes.flatMap((node: any) => {
|
||||
const selectedRank = Number(draft.specialization.nodeRanks[node.id] ?? 0);
|
||||
return selectedRank
|
||||
? [
|
||||
{
|
||||
node,
|
||||
rank: node.ranks.find(
|
||||
(entry: any) => Number(entry.rank) === selectedRank,
|
||||
),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
export function catalogAttribute(catalog: LoadoutCatalog, id: string) {
|
||||
return catalog.attributes.find((item) => item.id === id);
|
||||
}
|
||||
|
||||
export function applyAttributeValue(
|
||||
catalog: LoadoutCatalog,
|
||||
current: number,
|
||||
statId: string,
|
||||
incoming: number,
|
||||
) {
|
||||
const stat = catalogAttribute(catalog, statId);
|
||||
const combined =
|
||||
stat?.calculation_type === 'Multiplicative' && stat?.unit === '%'
|
||||
? ((1 + current / 100) * (1 + incoming / 100) - 1) * 100
|
||||
: current + incoming;
|
||||
return stat?.cap_value === null || stat?.cap_value === undefined
|
||||
? combined
|
||||
: Math.min(combined, Number(stat.cap_value));
|
||||
}
|
||||
|
||||
function unitValue(catalog: LoadoutCatalog, value: unknown, statId: string) {
|
||||
const numeric = Number(value);
|
||||
const rendered = Number.isInteger(numeric)
|
||||
? String(numeric)
|
||||
: numeric.toFixed(2).replace(/\.?0+$/, '');
|
||||
const unit = catalogAttribute(catalog, statId)?.unit ?? '';
|
||||
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`;
|
||||
}
|
||||
|
||||
function talentTier(
|
||||
catalog: LoadoutCatalog,
|
||||
talentId: string,
|
||||
tier: TalentTier,
|
||||
) {
|
||||
const item = catalog.talents.find((entry) => entry.id === talentId);
|
||||
return item?.tiers.find(
|
||||
(entry: any) => entry.tier === (tier === 2 ? 'II' : 'I'),
|
||||
);
|
||||
}
|
||||
|
||||
function talentDescription(
|
||||
catalog: LoadoutCatalog,
|
||||
talentId: string,
|
||||
tier: TalentTier,
|
||||
) {
|
||||
const item = catalog.talents.find((entry) => entry.id === talentId);
|
||||
const configured = talentTier(catalog, talentId, tier);
|
||||
if (!item || !configured) return '';
|
||||
const template = String(item.template || configured.description || item.name);
|
||||
return template.replace(/\{(\d+)\}/g, (placeholder, raw) => {
|
||||
const effect = configured.effects[Number(raw) - 1];
|
||||
return effect
|
||||
? unitValue(catalog, effect.value, effect.stat_attribute_id)
|
||||
: placeholder;
|
||||
});
|
||||
}
|
||||
|
||||
export function summarizeLoadout(
|
||||
draft: LoadoutDraft,
|
||||
catalog: LoadoutCatalog,
|
||||
): LoadoutSummary {
|
||||
const values = new Map<string, number>();
|
||||
const sources = new Map<string, Set<string>>();
|
||||
const add = (statId: string, value: unknown, source: string) => {
|
||||
if (
|
||||
!statId ||
|
||||
!catalogAttribute(catalog, statId) ||
|
||||
!Number.isFinite(Number(value))
|
||||
)
|
||||
return;
|
||||
values.set(
|
||||
statId,
|
||||
applyAttributeValue(
|
||||
catalog,
|
||||
values.get(statId) ?? 0,
|
||||
statId,
|
||||
Number(value),
|
||||
),
|
||||
);
|
||||
if (!sources.has(statId)) sources.set(statId, new Set());
|
||||
sources.get(statId)!.add(source);
|
||||
};
|
||||
const byName = (name: string) =>
|
||||
catalog.attributes.find(
|
||||
(item) => item.name.toLocaleLowerCase() === name.toLocaleLowerCase(),
|
||||
)?.id ?? '';
|
||||
const brandCounts = new Map<string, number>();
|
||||
const activeBonuses: any[] = [];
|
||||
const equippedTalents: any[] = [];
|
||||
const abilityValues = new Map<string, Map<string, number>>();
|
||||
const abilitySources = new Map<
|
||||
string,
|
||||
Map<
|
||||
string,
|
||||
Array<{
|
||||
source: string;
|
||||
chipIndex: number;
|
||||
chipSetId: string;
|
||||
value: number;
|
||||
}>
|
||||
>
|
||||
>();
|
||||
const addAbilityStat = (
|
||||
abilityId: string,
|
||||
statId: string,
|
||||
value: unknown,
|
||||
chipIndex: number,
|
||||
chipSetId: string,
|
||||
) => {
|
||||
if (
|
||||
!abilityId ||
|
||||
!statId ||
|
||||
!catalogAttribute(catalog, statId) ||
|
||||
!Number.isFinite(Number(value))
|
||||
)
|
||||
return;
|
||||
if (!abilityValues.has(abilityId)) abilityValues.set(abilityId, new Map());
|
||||
const valuesForAbility = abilityValues.get(abilityId)!;
|
||||
valuesForAbility.set(
|
||||
statId,
|
||||
applyAttributeValue(
|
||||
catalog,
|
||||
valuesForAbility.get(statId) ?? 0,
|
||||
statId,
|
||||
Number(value),
|
||||
),
|
||||
);
|
||||
if (!abilitySources.has(abilityId))
|
||||
abilitySources.set(abilityId, new Map());
|
||||
const sourcesForAbility = abilitySources.get(abilityId)!;
|
||||
if (!sourcesForAbility.has(statId)) sourcesForAbility.set(statId, []);
|
||||
sourcesForAbility.get(statId)!.push({
|
||||
source: `Skill Chip ${chipIndex + 1} primary`,
|
||||
chipIndex,
|
||||
chipSetId,
|
||||
value: Number(value),
|
||||
});
|
||||
};
|
||||
|
||||
for (const slot of gearSlots) {
|
||||
const selection = draft.gear[slot];
|
||||
const item = catalog.gear.find((entry) => entry.id === selection.itemId);
|
||||
if (!item) continue;
|
||||
const enabledAugments = catalog.gearAugments.filter(
|
||||
(entry) =>
|
||||
entry.gear_slot === slot &&
|
||||
selection.augmentSlots.includes(Number(entry.augment_slot)),
|
||||
);
|
||||
const basePercentage = enabledAugments
|
||||
.filter((entry) => entry.effect_type === 'Base Percentage')
|
||||
.reduce((total, entry) => total + Number(entry.value), 0);
|
||||
add(
|
||||
byName('Health'),
|
||||
selection.health * (1 + basePercentage / 100),
|
||||
`${slot} base`,
|
||||
);
|
||||
if (item.base_armor_min !== null)
|
||||
add(
|
||||
byName('Armor'),
|
||||
selection.armor * (1 + basePercentage / 100),
|
||||
`${slot} base`,
|
||||
);
|
||||
for (const roll of selection.rolls)
|
||||
add(roll.statId, roll.value, `${slot} roll`);
|
||||
for (const key of coreKeys)
|
||||
add(byName(key), selection[key], `${slot} core`);
|
||||
if (selection.brandId)
|
||||
brandCounts.set(
|
||||
selection.brandId,
|
||||
(brandCounts.get(selection.brandId) ?? 0) + 1,
|
||||
);
|
||||
for (const augment of enabledAugments.filter(
|
||||
(entry) => entry.effect_type === 'Custom',
|
||||
)) {
|
||||
add(
|
||||
augment.stat_attribute_id,
|
||||
augment.value,
|
||||
`${slot} augment ${augment.augment_slot}`,
|
||||
);
|
||||
}
|
||||
if (selection.talentId) {
|
||||
equippedTalents.push({
|
||||
key: `gear:${slot}:${selection.talentId}`,
|
||||
id: selection.talentId,
|
||||
rule:
|
||||
catalog.talents.find((entry) => entry.id === selection.talentId)
|
||||
?.rule ?? null,
|
||||
tier: selection.talentTier,
|
||||
tierLabel: selection.talentTier === 2 ? 'TIER II' : 'TIER I',
|
||||
isExotic: false,
|
||||
source: slot,
|
||||
description: talentDescription(
|
||||
catalog,
|
||||
selection.talentId,
|
||||
selection.talentTier,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const [brandId, count] of brandCounts) {
|
||||
const set = catalog.brands.find((entry) => entry.id === brandId);
|
||||
for (const effect of set?.effects.filter(
|
||||
(entry: any) => Number(entry.pieces_required) <= count,
|
||||
) ?? []) {
|
||||
add(
|
||||
effect.stat_attribute_id,
|
||||
effect.value,
|
||||
`${set.name} ${effect.pieces_required}-piece`,
|
||||
);
|
||||
activeBonuses.push({
|
||||
name: set.name,
|
||||
pieces: effect.pieces_required,
|
||||
count,
|
||||
statId: effect.stat_attribute_id,
|
||||
value: effect.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (let index = 0; index < 2; index += 1) {
|
||||
const selection = draft.weapons[index];
|
||||
const item = catalog.weapons.find((entry) => entry.id === selection.itemId);
|
||||
if (!item) continue;
|
||||
for (const name of ['Firepower', 'Toughness', 'Engineering'] as const) {
|
||||
add(
|
||||
byName(name),
|
||||
selection[name.toLowerCase() as 'firepower'],
|
||||
`${index ? 'Secondary' : 'Primary'} weapon`,
|
||||
);
|
||||
}
|
||||
for (const bonus of item.baseBonuses ?? [])
|
||||
add(bonus.stat_attribute_id, bonus.value, `${item.name} base bonus`);
|
||||
for (const roll of selection.rolls)
|
||||
add(
|
||||
roll.statId,
|
||||
roll.value,
|
||||
`${index ? 'Secondary' : 'Primary'} weapon roll`,
|
||||
);
|
||||
const equippedWeaponTalents = [
|
||||
{
|
||||
id: selection.talent1Id,
|
||||
tier: selection.talent1Tier,
|
||||
isExotic: false,
|
||||
},
|
||||
{
|
||||
id: selection.talent2Id,
|
||||
tier: selection.talent2Tier,
|
||||
isExotic: false,
|
||||
},
|
||||
{
|
||||
id: item.exotic_talent_id,
|
||||
tier: selection.exoticTalentTier,
|
||||
isExotic: true,
|
||||
},
|
||||
].filter((entry) => entry.id);
|
||||
for (const equippedTalent of equippedWeaponTalents) {
|
||||
equippedTalents.push({
|
||||
key: `weapon:${index}:${equippedTalent.id}`,
|
||||
id: equippedTalent.id,
|
||||
rule:
|
||||
catalog.talents.find((entry) => entry.id === equippedTalent.id)
|
||||
?.rule ?? null,
|
||||
tier: equippedTalent.tier,
|
||||
tierLabel: equippedTalent.tier === 2 ? 'TIER II' : 'TIER I',
|
||||
isExotic: equippedTalent.isExotic,
|
||||
source: index ? 'Secondary weapon' : 'Primary weapon',
|
||||
description: talentDescription(
|
||||
catalog,
|
||||
equippedTalent.id,
|
||||
equippedTalent.tier,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const chipGroups = new Map<
|
||||
string,
|
||||
{ count: number; highEnd: number; item: any; abilityId: string }
|
||||
>();
|
||||
const focus = selectedFocus(draft, catalog);
|
||||
const validChipRoll = (
|
||||
kind: 'primaryOptions' | 'secondaryOptions',
|
||||
quality: string,
|
||||
roll: { statId: string; value: number },
|
||||
) => {
|
||||
if (!catalog.skillChipProfile) return true;
|
||||
const range = catalog.skillChipProfile[kind].find(
|
||||
(option) => option.statId === roll.statId,
|
||||
)?.ranges?.[quality];
|
||||
return (
|
||||
range &&
|
||||
roll.value >= Number(range.min) &&
|
||||
roll.value <= Number(range.max)
|
||||
);
|
||||
};
|
||||
for (let index = 0; index < 9; index += 1) {
|
||||
const selection = draft.chips[index];
|
||||
const item = catalog.chipSets.find((entry) => entry.id === selection.setId);
|
||||
if (!item) continue;
|
||||
const slotAbilityId = String(
|
||||
focus?.abilities?.[Math.floor(index / 3)]?.id ?? '',
|
||||
);
|
||||
const compatibleAbilityIds = item.compatibleAbilityIds ?? [
|
||||
String(item.specialization_ability_id ?? ''),
|
||||
];
|
||||
if (slotAbilityId && !compatibleAbilityIds.includes(slotAbilityId))
|
||||
continue;
|
||||
const abilityId =
|
||||
slotAbilityId || String(item.specialization_ability_id ?? '');
|
||||
if (validChipRoll('primaryOptions', selection.quality, selection.primary))
|
||||
addAbilityStat(
|
||||
abilityId,
|
||||
selection.primary.statId,
|
||||
selection.primary.value,
|
||||
index,
|
||||
String(item.id),
|
||||
);
|
||||
for (const roll of selection.secondary)
|
||||
if (validChipRoll('secondaryOptions', selection.quality, roll))
|
||||
add(roll.statId, roll.value, `Skill Chip ${index + 1} secondary`);
|
||||
const groupKey = `${item.id}:${abilityId}`;
|
||||
const group = chipGroups.get(groupKey) ?? {
|
||||
count: 0,
|
||||
highEnd: 0,
|
||||
item,
|
||||
abilityId,
|
||||
};
|
||||
group.count += 1;
|
||||
if (selection.quality === 'High-end') group.highEnd += 1;
|
||||
chipGroups.set(groupKey, group);
|
||||
}
|
||||
for (const group of chipGroups.values()) {
|
||||
if (group.count >= 2 && group.item.twoPiece) {
|
||||
const value = scaled(
|
||||
group.item.twoPiece.min_value,
|
||||
group.item.twoPiece.max_value,
|
||||
group.highEnd,
|
||||
3,
|
||||
);
|
||||
add(
|
||||
group.item.twoPiece.stat_attribute_id,
|
||||
value,
|
||||
`${group.item.name} 2-piece`,
|
||||
);
|
||||
activeBonuses.push({
|
||||
name: group.item.name,
|
||||
pieces: 2,
|
||||
count: group.count,
|
||||
statId: group.item.twoPiece.stat_attribute_id,
|
||||
value,
|
||||
});
|
||||
}
|
||||
if (group.count === 3 && group.item.talent) {
|
||||
equippedTalents.push({
|
||||
key: `chips:${group.item.id}:${group.abilityId}`,
|
||||
id: '',
|
||||
custom: group.item.talent,
|
||||
rule: group.item.talent.rule ?? null,
|
||||
tier: group.highEnd,
|
||||
abilityId: group.abilityId || null,
|
||||
source: '3 Skill Chips',
|
||||
description: group.item.talent.description_template.replace(
|
||||
/\{(\d+)\}/g,
|
||||
(placeholder: string, raw: string) => {
|
||||
const effect = group.item.talent.effects[Number(raw) - 1];
|
||||
return effect
|
||||
? unitValue(
|
||||
catalog,
|
||||
scaled(effect.min_value, effect.max_value, group.highEnd, 3),
|
||||
effect.stat_attribute_id,
|
||||
)
|
||||
: placeholder;
|
||||
},
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const { node, rank } of selectedNodeRanks(draft, catalog)) {
|
||||
for (const effect of rank?.effects ?? []) {
|
||||
if (effect.effect_type === 'Attribute')
|
||||
add(
|
||||
effect.stat_attribute_id,
|
||||
effect.value,
|
||||
`${node.name} rank ${rank.rank}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const protocol = catalog.protocols.find(
|
||||
(entry) => entry.id === draft.protocol.itemId,
|
||||
);
|
||||
if (protocol) {
|
||||
if (protocol.primaryEffect) {
|
||||
add(
|
||||
protocol.primaryEffect.stat_attribute_id,
|
||||
Number(protocol.primaryEffect.base_value) *
|
||||
(1 + (draft.protocol.level - 1) / 49),
|
||||
`${protocol.name} primary`,
|
||||
);
|
||||
}
|
||||
for (const effect of protocol.secondaryEffects ?? []) {
|
||||
add(effect.stat_attribute_id, effect.value, `${protocol.name} secondary`);
|
||||
}
|
||||
if (protocol.talent) {
|
||||
equippedTalents.push({
|
||||
key: `protocol:${protocol.id}`,
|
||||
id: '',
|
||||
custom: protocol.talent,
|
||||
rule: protocol.talent.rule ?? null,
|
||||
tier: draft.protocol.talentUpgrades,
|
||||
source: 'OS Protocol',
|
||||
description: protocol.talent.description_template.replace(
|
||||
/\{(\d+)\}/g,
|
||||
(placeholder: string, raw: string) => {
|
||||
const effect = protocol.talent.effects[Number(raw) - 1];
|
||||
return effect
|
||||
? unitValue(
|
||||
catalog,
|
||||
Number(effect.base_value) *
|
||||
(1 + draft.protocol.talentUpgrades / 5),
|
||||
effect.stat_attribute_id,
|
||||
)
|
||||
: placeholder;
|
||||
},
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
stats: [...values]
|
||||
.map(([id, value]) => ({
|
||||
id,
|
||||
value,
|
||||
sourceCount: sources.get(id)?.size ?? 0,
|
||||
}))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
(catalogAttribute(catalog, left.id)?.category ?? '').localeCompare(
|
||||
catalogAttribute(catalog, right.id)?.category ?? '',
|
||||
) ||
|
||||
(catalogAttribute(catalog, left.id)?.name ?? '').localeCompare(
|
||||
catalogAttribute(catalog, right.id)?.name ?? '',
|
||||
),
|
||||
),
|
||||
abilityStats: [...abilityValues].map(([abilityId, abilityStatValues]) => ({
|
||||
abilityId,
|
||||
stats: [...abilityStatValues]
|
||||
.map(([id, value]) => {
|
||||
const statSources = abilitySources.get(abilityId)?.get(id) ?? [];
|
||||
return {
|
||||
id,
|
||||
value,
|
||||
sourceCount: statSources.length,
|
||||
sources: statSources,
|
||||
};
|
||||
})
|
||||
.sort(
|
||||
(left, right) =>
|
||||
(catalogAttribute(catalog, left.id)?.category ?? '').localeCompare(
|
||||
catalogAttribute(catalog, right.id)?.category ?? '',
|
||||
) ||
|
||||
(catalogAttribute(catalog, left.id)?.name ?? '').localeCompare(
|
||||
catalogAttribute(catalog, right.id)?.name ?? '',
|
||||
),
|
||||
),
|
||||
})),
|
||||
activeBonuses,
|
||||
equippedTalents,
|
||||
};
|
||||
}
|
||||
@@ -1,20 +1,91 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createDraft, parseLoadoutDraft } from './loadout.js';
|
||||
import {
|
||||
createDraft,
|
||||
migrateLoadoutDraftV5,
|
||||
parseLoadoutDraft,
|
||||
skillChipSecondaryCapacity,
|
||||
type SkillChipQuality,
|
||||
} from './loadout.js';
|
||||
|
||||
const copyDraft = () => structuredClone(createDraft());
|
||||
|
||||
const talentTierFields = [
|
||||
[
|
||||
'gear talentTier',
|
||||
(draft: ReturnType<typeof copyDraft>) =>
|
||||
draft.gear.Holster as unknown as Record<string, unknown>,
|
||||
'talentTier',
|
||||
],
|
||||
[
|
||||
'weapon talent1Tier',
|
||||
(draft: ReturnType<typeof copyDraft>) =>
|
||||
draft.weapons[0] as unknown as Record<string, unknown>,
|
||||
'talent1Tier',
|
||||
],
|
||||
[
|
||||
'weapon talent2Tier',
|
||||
(draft: ReturnType<typeof copyDraft>) =>
|
||||
draft.weapons[0] as unknown as Record<string, unknown>,
|
||||
'talent2Tier',
|
||||
],
|
||||
[
|
||||
'weapon exoticTalentTier',
|
||||
(draft: ReturnType<typeof copyDraft>) =>
|
||||
draft.weapons[0] as unknown as Record<string, unknown>,
|
||||
'exoticTalentTier',
|
||||
],
|
||||
] as const;
|
||||
|
||||
describe('parseLoadoutDraft', () => {
|
||||
it('accepts a current draft and returns an independent clone', () => {
|
||||
const draft = createDraft();
|
||||
const parsed = parseLoadoutDraft(draft);
|
||||
|
||||
expect(parsed).toEqual(draft);
|
||||
expect(parsed?.version).toBe(6);
|
||||
expect(parsed?.chips[0]).toEqual({
|
||||
setId: '',
|
||||
quality: 'Standard',
|
||||
primary: { statId: '', value: 0 },
|
||||
secondary: [],
|
||||
});
|
||||
expect(parsed?.calculationMode).toBe('maximum');
|
||||
expect(parsed).not.toBe(draft);
|
||||
parsed!.gear.Holster.level = 1;
|
||||
expect(draft.gear.Holster.level).toBe(50);
|
||||
});
|
||||
|
||||
it('defaults every equipment Talent tier to Tier I', () => {
|
||||
const draft = createDraft();
|
||||
|
||||
expect(
|
||||
Object.values(draft.gear).map(
|
||||
(selection) =>
|
||||
(selection as unknown as Record<string, unknown>).talentTier,
|
||||
),
|
||||
).toEqual(Array(6).fill(1));
|
||||
expect(
|
||||
draft.weapons.map((selection) => {
|
||||
const weapon = selection as unknown as Record<string, unknown>;
|
||||
return [
|
||||
weapon.talent1Tier,
|
||||
weapon.talent2Tier,
|
||||
weapon.exoticTalentTier,
|
||||
];
|
||||
}),
|
||||
).toEqual([
|
||||
[1, 1, 1],
|
||||
[1, 1, 1],
|
||||
]);
|
||||
});
|
||||
|
||||
it('accepts Tier II for every equipment Talent selection', () => {
|
||||
const draft = copyDraft();
|
||||
for (const [, select, key] of talentTierFields) select(draft)[key] = 2;
|
||||
|
||||
expect(parseLoadoutDraft(draft)).toEqual(draft);
|
||||
});
|
||||
|
||||
it('rejects the retired version 1 three-chip shape', () => {
|
||||
const retired = copyDraft() as unknown as Record<string, unknown>;
|
||||
retired.version = 1;
|
||||
@@ -23,13 +94,125 @@ describe('parseLoadoutDraft', () => {
|
||||
expect(parseLoadoutDraft(retired)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a version 4 draft that does not contain exactly nine chips', () => {
|
||||
it('rejects the retired version 4 schema', () => {
|
||||
const retired = copyDraft() as unknown as Record<string, unknown>;
|
||||
retired.version = 4;
|
||||
|
||||
expect(parseLoadoutDraft(retired)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects the retired version 5 schema', () => {
|
||||
const retired = copyDraft() as unknown as Record<string, unknown>;
|
||||
retired.version = 5;
|
||||
|
||||
expect(parseLoadoutDraft(retired)).toBeNull();
|
||||
});
|
||||
|
||||
it('migrates version 5 equipment-derived Talent tiers once', () => {
|
||||
const legacy = copyDraft() as unknown as Record<string, any>;
|
||||
legacy.version = 5;
|
||||
for (const selection of Object.values(legacy.gear) as Record<
|
||||
string,
|
||||
any
|
||||
>[]) {
|
||||
delete selection.talentTier;
|
||||
}
|
||||
for (const selection of legacy.weapons as Record<string, any>[]) {
|
||||
delete selection.talent1Tier;
|
||||
delete selection.talent2Tier;
|
||||
delete selection.exoticTalentTier;
|
||||
}
|
||||
legacy.gear.Holster.itemId = 'tier-two-gear';
|
||||
legacy.weapons[0].itemId = 'exotic-weapon';
|
||||
legacy.weapons[0].tier = 2;
|
||||
|
||||
const migrated = migrateLoadoutDraftV5(legacy, {
|
||||
gear: [{ id: 'tier-two-gear', tier: 2 }],
|
||||
weapons: [{ id: 'exotic-weapon', exotic_talent_id: 'exotic-talent' }],
|
||||
talents: [
|
||||
{
|
||||
id: 'exotic-talent',
|
||||
tiers: [{ tier: 'I' }, { tier: 'II' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(migrated?.version).toBe(6);
|
||||
expect(migrated?.gear.Holster.talentTier).toBe(2);
|
||||
expect(migrated?.gear.Gloves.talentTier).toBe(1);
|
||||
expect(migrated?.weapons[0]).toMatchObject({
|
||||
talent1Tier: 2,
|
||||
talent2Tier: 2,
|
||||
exoticTalentTier: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a current draft that does not contain exactly nine chips', () => {
|
||||
const draft = copyDraft();
|
||||
draft.chips.pop();
|
||||
|
||||
expect(parseLoadoutDraft(draft)).toBeNull();
|
||||
});
|
||||
|
||||
it.each(talentTierFields)('rejects a draft missing %s', (_, select, key) => {
|
||||
const draft = copyDraft();
|
||||
delete select(draft)[key];
|
||||
|
||||
expect(parseLoadoutDraft(draft)).toBeNull();
|
||||
});
|
||||
|
||||
it.each(
|
||||
talentTierFields.flatMap(([name, select, key]) =>
|
||||
[0, 1.5, 3].map((value) => [name, value, select, key] as const),
|
||||
),
|
||||
)('rejects %s value %s', (_, value, select, key) => {
|
||||
const draft = copyDraft();
|
||||
select(draft)[key] = value;
|
||||
|
||||
expect(parseLoadoutDraft(draft)).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['Standard', 0],
|
||||
['Specialized', 1],
|
||||
['Superior', 2],
|
||||
['High-end', 2],
|
||||
] as const)(
|
||||
'accepts %s chips with exactly %i secondary rolls',
|
||||
(quality, capacity) => {
|
||||
const draft = copyDraft();
|
||||
draft.chips[0].quality = quality;
|
||||
draft.chips[0].primary = { statId: 'primary', value: 10 };
|
||||
draft.chips[0].secondary = Array.from(
|
||||
{ length: capacity },
|
||||
(_, index) => ({ statId: `secondary-${index}`, value: index + 1 }),
|
||||
);
|
||||
|
||||
expect(skillChipSecondaryCapacity[quality]).toBe(capacity);
|
||||
expect(parseLoadoutDraft(draft)).toEqual(draft);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
['Standard', 1],
|
||||
['Specialized', 0],
|
||||
['Specialized', 2],
|
||||
['Superior', 1],
|
||||
['High-end', 3],
|
||||
] as Array<[SkillChipQuality, number]>)(
|
||||
'rejects %s chips with %i secondary rolls',
|
||||
(quality, capacity) => {
|
||||
const draft = copyDraft();
|
||||
draft.chips[0].quality = quality;
|
||||
draft.chips[0].secondary = Array.from(
|
||||
{ length: capacity },
|
||||
(_, index) => ({ statId: `secondary-${index}`, value: index + 1 }),
|
||||
);
|
||||
|
||||
expect(parseLoadoutDraft(draft)).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects a draft missing the unified combat profile', () => {
|
||||
const draft = copyDraft() as unknown as Record<string, unknown>;
|
||||
delete draft.combatProfile;
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
export type Roll = { statId: string; value: number };
|
||||
export type TalentTier = 1 | 2;
|
||||
export const skillChipQualities = [
|
||||
'Standard',
|
||||
'Specialized',
|
||||
'Superior',
|
||||
'High-end',
|
||||
] as const;
|
||||
export type SkillChipQuality = (typeof skillChipQualities)[number];
|
||||
export const skillChipSecondaryCapacity: Record<SkillChipQuality, number> = {
|
||||
Standard: 0,
|
||||
Specialized: 1,
|
||||
Superior: 2,
|
||||
'High-end': 2,
|
||||
};
|
||||
export type GearSelection = {
|
||||
itemId: string;
|
||||
level: number;
|
||||
brandId: string;
|
||||
talentId: string;
|
||||
talentTier: TalentTier;
|
||||
health: number;
|
||||
armor: number;
|
||||
firepower: number;
|
||||
@@ -21,14 +36,17 @@ export type WeaponSelection = {
|
||||
toughness: number;
|
||||
engineering: number;
|
||||
talent1Id: string;
|
||||
talent1Tier: TalentTier;
|
||||
talent2Id: string;
|
||||
talent2Tier: TalentTier;
|
||||
exoticTalentTier: TalentTier;
|
||||
rolls: [Roll, Roll];
|
||||
};
|
||||
export type ChipSelection = {
|
||||
setId: string;
|
||||
quality: 'Superior' | 'High-end';
|
||||
quality: SkillChipQuality;
|
||||
primary: Roll;
|
||||
secondary: [Roll, Roll];
|
||||
secondary: Roll[];
|
||||
};
|
||||
export type ProtocolSelection = {
|
||||
itemId: string;
|
||||
@@ -68,7 +86,7 @@ export type SpecializationSelection = {
|
||||
nodeRanks: Record<string, number>;
|
||||
};
|
||||
export type LoadoutDraft = {
|
||||
version: 4;
|
||||
version: 6;
|
||||
buildName: string;
|
||||
calculationMode: CalculationMode;
|
||||
gear: Record<string, GearSelection>;
|
||||
@@ -105,6 +123,7 @@ export const blankGear = (): GearSelection => ({
|
||||
level: 50,
|
||||
brandId: '',
|
||||
talentId: '',
|
||||
talentTier: 1,
|
||||
health: 0,
|
||||
armor: 0,
|
||||
firepower: 0,
|
||||
@@ -122,14 +141,17 @@ export const blankWeapon = (): WeaponSelection => ({
|
||||
toughness: 0,
|
||||
engineering: 0,
|
||||
talent1Id: '',
|
||||
talent1Tier: 1,
|
||||
talent2Id: '',
|
||||
talent2Tier: 1,
|
||||
exoticTalentTier: 1,
|
||||
rolls: [blankRoll(), blankRoll()],
|
||||
});
|
||||
export const blankChip = (): ChipSelection => ({
|
||||
setId: '',
|
||||
quality: 'Superior',
|
||||
quality: 'Standard',
|
||||
primary: blankRoll(),
|
||||
secondary: [blankRoll(), blankRoll()],
|
||||
secondary: [],
|
||||
});
|
||||
export const defaultCombatProfile = (): CombatProfile => ({
|
||||
encounterSeconds: 60,
|
||||
@@ -148,7 +170,7 @@ export const defaultCombatProfile = (): CombatProfile => ({
|
||||
extraHealthActive: false,
|
||||
});
|
||||
export const createDraft = (): LoadoutDraft => ({
|
||||
version: 4,
|
||||
version: 6,
|
||||
buildName: 'Untitled loadout',
|
||||
calculationMode: 'maximum',
|
||||
gear: Object.fromEntries(gearSlots.map((slot) => [slot, blankGear()])),
|
||||
@@ -174,6 +196,39 @@ export const createDraft = (): LoadoutDraft => ({
|
||||
},
|
||||
});
|
||||
|
||||
export function migrateLoadoutDraftV5(
|
||||
value: unknown,
|
||||
catalog: { gear: any[]; weapons: any[]; talents: any[] },
|
||||
): LoadoutDraft | null {
|
||||
if (!isRecord(value) || value.version !== 5) return null;
|
||||
const migrated = structuredClone(value);
|
||||
if (!isRecord(migrated.gear) || !Array.isArray(migrated.weapons)) return null;
|
||||
|
||||
for (const slot of gearSlots) {
|
||||
const selection = migrated.gear[slot];
|
||||
if (!isRecord(selection)) return null;
|
||||
const item = catalog.gear.find((entry) => entry.id === selection.itemId);
|
||||
selection.talentTier = Number(item?.tier) >= 2 ? 2 : 1;
|
||||
}
|
||||
for (const selection of migrated.weapons) {
|
||||
if (!isRecord(selection)) return null;
|
||||
const inheritedTier: TalentTier = Number(selection.tier) >= 2 ? 2 : 1;
|
||||
selection.talent1Tier = inheritedTier;
|
||||
selection.talent2Tier = inheritedTier;
|
||||
const item = catalog.weapons.find((entry) => entry.id === selection.itemId);
|
||||
const exoticTalent = catalog.talents.find(
|
||||
(entry) => entry.id === item?.exotic_talent_id,
|
||||
);
|
||||
selection.exoticTalentTier =
|
||||
inheritedTier === 2 &&
|
||||
exoticTalent?.tiers?.some((entry: any) => entry.tier === 'II')
|
||||
? 2
|
||||
: 1;
|
||||
}
|
||||
migrated.version = 6;
|
||||
return parseLoadoutDraft(migrated);
|
||||
}
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
const hasExactKeys = (value: Record<string, unknown>, keys: string[]) =>
|
||||
@@ -181,6 +236,8 @@ const hasExactKeys = (value: Record<string, unknown>, keys: string[]) =>
|
||||
keys.every((key) => key in value);
|
||||
const isFiniteNumber = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isFinite(value);
|
||||
const isTalentTier = (value: unknown): value is TalentTier =>
|
||||
value === 1 || value === 2;
|
||||
const isNumberRecord = (value: unknown) =>
|
||||
isRecord(value) &&
|
||||
Object.values(value).every(
|
||||
@@ -200,6 +257,7 @@ const isGearSelection = (value: unknown): value is GearSelection =>
|
||||
'level',
|
||||
'brandId',
|
||||
'talentId',
|
||||
'talentTier',
|
||||
'health',
|
||||
'armor',
|
||||
'firepower',
|
||||
@@ -211,6 +269,7 @@ const isGearSelection = (value: unknown): value is GearSelection =>
|
||||
['itemId', 'brandId', 'talentId'].every(
|
||||
(key) => typeof value[key] === 'string',
|
||||
) &&
|
||||
isTalentTier(value.talentTier) &&
|
||||
['level', 'health', 'armor', 'firepower', 'toughness', 'engineering'].every(
|
||||
(key) => isFiniteNumber(value[key]),
|
||||
) &&
|
||||
@@ -228,12 +287,18 @@ const isWeaponSelection = (value: unknown): value is WeaponSelection =>
|
||||
'toughness',
|
||||
'engineering',
|
||||
'talent1Id',
|
||||
'talent1Tier',
|
||||
'talent2Id',
|
||||
'talent2Tier',
|
||||
'exoticTalentTier',
|
||||
'rolls',
|
||||
]) &&
|
||||
['itemId', 'talent1Id', 'talent2Id'].every(
|
||||
(key) => typeof value[key] === 'string',
|
||||
) &&
|
||||
isTalentTier(value.talent1Tier) &&
|
||||
isTalentTier(value.talent2Tier) &&
|
||||
isTalentTier(value.exoticTalentTier) &&
|
||||
['level', 'tier', 'damage', 'firepower', 'toughness', 'engineering'].every(
|
||||
(key) => isFiniteNumber(value[key]),
|
||||
) &&
|
||||
@@ -242,9 +307,12 @@ const isChipSelection = (value: unknown): value is ChipSelection =>
|
||||
isRecord(value) &&
|
||||
hasExactKeys(value, ['setId', 'quality', 'primary', 'secondary']) &&
|
||||
typeof value.setId === 'string' &&
|
||||
(value.quality === 'Superior' || value.quality === 'High-end') &&
|
||||
skillChipQualities.some((quality) => value.quality === quality) &&
|
||||
isRoll(value.primary) &&
|
||||
isRollPair(value.secondary);
|
||||
Array.isArray(value.secondary) &&
|
||||
value.secondary.length ===
|
||||
skillChipSecondaryCapacity[value.quality as SkillChipQuality] &&
|
||||
value.secondary.every(isRoll);
|
||||
const isProtocolSelection = (value: unknown): value is ProtocolSelection =>
|
||||
isRecord(value) &&
|
||||
hasExactKeys(value, ['itemId', 'level', 'talentUpgrades']) &&
|
||||
@@ -353,7 +421,7 @@ export function parseLoadoutDraft(value: unknown): LoadoutDraft | null {
|
||||
'skillActivations',
|
||||
'specialization',
|
||||
]) ||
|
||||
value.version !== 4 ||
|
||||
value.version !== 6 ||
|
||||
typeof value.buildName !== 'string' ||
|
||||
!['maximum', 'custom'].includes(String(value.calculationMode))
|
||||
)
|
||||
|
||||
@@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
currentHealthPercentBandPredicate,
|
||||
eventFieldComparisonPredicate,
|
||||
mechanicTargetingForMode,
|
||||
mechanicTargetingMode,
|
||||
parseConditionalMechanicDraft,
|
||||
readCurrentHealthPercentBand,
|
||||
readEventFieldComparison,
|
||||
@@ -200,6 +202,110 @@ describe('conditional mechanic authoring payload', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('exposes assigned-ability activity for kill and assist event conditions', () => {
|
||||
expect(registeredEventFields('enemy_killed')).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
path: 'values.assignedAbilityActive',
|
||||
label: 'Assigned ability active',
|
||||
type: 'boolean',
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(registeredEventFields('assist')).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
path: 'values.assignedAbilityActive',
|
||||
label: 'Assigned ability active',
|
||||
type: 'boolean',
|
||||
},
|
||||
]),
|
||||
);
|
||||
const value = draft();
|
||||
value.clauses[0].event = 'enemy_killed';
|
||||
value.clauses[0].recipeKey = undefined;
|
||||
value.clauses[0].reducer = { kind: 'none' };
|
||||
value.clauses[0].if = eventFieldComparisonPredicate({
|
||||
path: 'values.assignedAbilityActive',
|
||||
operator: 'eq',
|
||||
value: true,
|
||||
});
|
||||
|
||||
const result = parseConditionalMechanicDraft(value, {
|
||||
variantKeys: ['I', 'II'],
|
||||
allowedAttributeIds: new Set(['weapon-damage']),
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value.programs.I.clauses[0]).toMatchObject({
|
||||
when: 'enemy_killed',
|
||||
if: value.clauses[0].if,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses deterministic outcome and clause proc chances with ability mutations', () => {
|
||||
const value = draft();
|
||||
value.clauses[0].event = 'skill_hit';
|
||||
value.clauses[0].recipeKey = undefined;
|
||||
value.clauses[0].procChancePercent = 10;
|
||||
value.clauses[0].requiresAssignedAbilityActive = true;
|
||||
value.clauses[0].outcomes[0].probabilityPercent = 29;
|
||||
value.clauses[0].outcomes[0].statePool = undefined;
|
||||
value.clauses[0].outcomes[0].perStack = false;
|
||||
value.pools = [
|
||||
{
|
||||
key: 'extension-stacks',
|
||||
label: 'Extension stacks',
|
||||
stateKind: 'stack',
|
||||
scope: 'agent',
|
||||
initial: 0,
|
||||
min: 0,
|
||||
max: 8,
|
||||
},
|
||||
{
|
||||
key: 'extension-cooldown',
|
||||
label: 'Extension cooldown',
|
||||
stateKind: 'flag',
|
||||
scope: 'agent',
|
||||
initial: 0,
|
||||
min: 0,
|
||||
max: 1,
|
||||
expiresAfterSeconds: 15,
|
||||
},
|
||||
];
|
||||
value.clauses[0].mutations = [
|
||||
{ kind: 'add', pool: 'extension-stacks', value: 1 },
|
||||
{ kind: 'extend_duration', target: 'assigned_ability', seconds: 0.3 },
|
||||
{
|
||||
kind: 'start_cooldown_on_max',
|
||||
stackPool: 'extension-stacks',
|
||||
cooldownPool: 'extension-cooldown',
|
||||
},
|
||||
];
|
||||
const result = parseConditionalMechanicDraft(value, {
|
||||
variantKeys: ['I', 'II'],
|
||||
allowedAttributeIds: new Set(['weapon-damage']),
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value.programs.I.clauses[0]).toMatchObject({
|
||||
procChancePercent: 10,
|
||||
requiresAssignedAbilityActive: true,
|
||||
outcomes: [{ probabilityPercent: 29 }],
|
||||
});
|
||||
expect(result.value.programs.I.clauses[0].mutations).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ kind: 'extend_duration', target: 'assigned_ability', seconds: 0.3 },
|
||||
{
|
||||
kind: 'start_cooldown_on_max',
|
||||
stackPool: 'extension-stacks',
|
||||
cooldownPool: 'extension-cooldown',
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an unregistered Extra Health event field deterministically', () => {
|
||||
const value = draft();
|
||||
value.clauses[0].event = 'extra_health_changed';
|
||||
@@ -685,9 +791,9 @@ describe('conditional mechanic authoring payload', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects invalid pre-event timing for current Health percentage references', () => {
|
||||
const invalid = draft();
|
||||
invalid.clauses[0].if = {
|
||||
it('preserves pre-event timing for current Health percentage references', () => {
|
||||
const value = draft();
|
||||
value.clauses[0].if = {
|
||||
kind: 'compare',
|
||||
left: {
|
||||
source: 'combat',
|
||||
@@ -697,11 +803,13 @@ describe('conditional mechanic authoring payload', () => {
|
||||
operator: 'lt',
|
||||
right: 50,
|
||||
};
|
||||
const result = parseConditionalMechanicDraft(invalid, {
|
||||
const result = parseConditionalMechanicDraft(value, {
|
||||
variantKeys: ['I', 'II'],
|
||||
allowedAttributeIds: new Set(['weapon-damage']),
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value.draft.clauses[0].if).toEqual(value.clauses[0].if);
|
||||
});
|
||||
|
||||
it('accepts compatible radius targeting and rejects incompatible scopes', () => {
|
||||
@@ -735,4 +843,125 @@ describe('conditional mechanic authoring payload', () => {
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('exposes aiming and round-trips event-target explosion editor state', () => {
|
||||
expect(registeredEventFields('weapon_hit')).toContainEqual({
|
||||
path: 'values.aiming',
|
||||
label: 'Aiming down sights',
|
||||
type: 'boolean',
|
||||
});
|
||||
const targeting = mechanicTargetingForMode('event_target_explosion');
|
||||
expect(targeting).toEqual({
|
||||
kind: 'event_target_area',
|
||||
center: 'event_target',
|
||||
validTargets: 'enemies',
|
||||
radiusMeters: 5,
|
||||
});
|
||||
expect(mechanicTargetingMode(targeting)).toBe('event_target_explosion');
|
||||
expect(mechanicTargetingForMode('none', targeting)).toBeUndefined();
|
||||
|
||||
const valid = draft();
|
||||
valid.clauses[0].if = eventFieldComparisonPredicate({
|
||||
path: 'values.aiming',
|
||||
operator: 'eq',
|
||||
value: true,
|
||||
});
|
||||
valid.clauses[0].outcomes[0] = {
|
||||
...valid.clauses[0].outcomes[0],
|
||||
kind: 'direct_damage',
|
||||
scope: 'target',
|
||||
phase: 'after_event',
|
||||
attributeId: undefined,
|
||||
targeting,
|
||||
};
|
||||
const parsed = parseConditionalMechanicDraft(valid, {
|
||||
variantKeys: ['I', 'II'],
|
||||
allowedAttributeIds: new Set(['weapon-damage']),
|
||||
});
|
||||
expect(parsed.ok).toBe(true);
|
||||
if (parsed.ok) {
|
||||
expect(parsed.value.draft.clauses[0].outcomes[0].targeting).toEqual(
|
||||
targeting,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects incomplete, mis-scoped, and non-event area damage', () => {
|
||||
for (const configure of [
|
||||
(candidate: ConditionalMechanicDraftV1) => {
|
||||
candidate.clauses[0].outcomes[0].targeting = {
|
||||
kind: 'event_target_area',
|
||||
center: 'event_target',
|
||||
validTargets: 'enemies',
|
||||
radiusMeters: 0,
|
||||
};
|
||||
},
|
||||
(candidate: ConditionalMechanicDraftV1) => {
|
||||
candidate.clauses[0].outcomes[0].scope = 'self';
|
||||
},
|
||||
(candidate: ConditionalMechanicDraftV1) => {
|
||||
candidate.clauses[0].outcomes[0].phase = 'while_active';
|
||||
},
|
||||
(candidate: ConditionalMechanicDraftV1) => {
|
||||
candidate.clauses[0].event = 'enemy_killed';
|
||||
},
|
||||
]) {
|
||||
const invalid = draft();
|
||||
invalid.clauses[0].outcomes[0] = {
|
||||
...invalid.clauses[0].outcomes[0],
|
||||
kind: 'direct_damage',
|
||||
scope: 'target',
|
||||
phase: 'after_event',
|
||||
attributeId: undefined,
|
||||
targeting: {
|
||||
kind: 'event_target_area',
|
||||
center: 'event_target',
|
||||
validTargets: 'enemies',
|
||||
radiusMeters: 5,
|
||||
},
|
||||
};
|
||||
configure(invalid);
|
||||
expect(
|
||||
parseConditionalMechanicDraft(invalid, {
|
||||
variantKeys: ['I', 'II'],
|
||||
allowedAttributeIds: new Set(['weapon-damage']),
|
||||
}).ok,
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('requires explicit equality for optional aiming evidence', () => {
|
||||
for (const predicate of [
|
||||
{
|
||||
kind: 'compare' as const,
|
||||
left: { source: 'event' as const, path: 'values.aiming' },
|
||||
operator: 'ne' as const,
|
||||
right: true,
|
||||
},
|
||||
{
|
||||
kind: 'not' as const,
|
||||
predicate: {
|
||||
kind: 'compare' as const,
|
||||
left: { source: 'event' as const, path: 'values.aiming' },
|
||||
operator: 'eq' as const,
|
||||
right: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'compare' as const,
|
||||
left: { source: 'event' as const, path: 'values.aiming' },
|
||||
operator: 'ne' as const,
|
||||
right: { source: 'combat' as const, path: 'currentHealth' },
|
||||
},
|
||||
]) {
|
||||
const invalid = draft();
|
||||
invalid.clauses[0].if = predicate;
|
||||
expect(
|
||||
parseConditionalMechanicDraft(invalid, {
|
||||
variantKeys: ['I', 'II'],
|
||||
allowedAttributeIds: new Set(['weapon-damage']),
|
||||
}).ok,
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import {
|
||||
MECHANIC_EVENT_KINDS,
|
||||
MECHANIC_EVENT_REGISTRY,
|
||||
mechanicOutcomeTargetingError,
|
||||
validateMechanicProgram,
|
||||
type ClauseMutation,
|
||||
type MechanicClause,
|
||||
type MechanicEventKind,
|
||||
type MechanicOutcome,
|
||||
type MechanicOutcomeScope,
|
||||
type MechanicOutcomeTargeting,
|
||||
type MechanicProgram,
|
||||
type MechanicResource,
|
||||
type NumericExpression,
|
||||
@@ -198,6 +200,44 @@ export function registeredEventFields(event: MechanicEventKind) {
|
||||
return MECHANIC_EVENT_REGISTRY[event].fields ?? [];
|
||||
}
|
||||
|
||||
export type MechanicTargetingMode =
|
||||
| 'none'
|
||||
| 'allies_within_radius'
|
||||
| 'enemies_within_radius'
|
||||
| 'event_target_explosion';
|
||||
|
||||
export function mechanicTargetingMode(
|
||||
targeting: MechanicOutcomeTargeting | undefined,
|
||||
): MechanicTargetingMode {
|
||||
if (!targeting) return 'none';
|
||||
if (targeting.kind === 'event_target_area') return 'event_target_explosion';
|
||||
return targeting.population === 'allies'
|
||||
? 'allies_within_radius'
|
||||
: 'enemies_within_radius';
|
||||
}
|
||||
|
||||
export function mechanicTargetingForMode(
|
||||
mode: MechanicTargetingMode,
|
||||
current?: MechanicOutcomeTargeting,
|
||||
): MechanicOutcomeTargeting | undefined {
|
||||
if (mode === 'none') return undefined;
|
||||
const radiusMeters =
|
||||
current?.radiusMeters ?? (mode === 'event_target_explosion' ? 5 : 20);
|
||||
if (mode === 'event_target_explosion') {
|
||||
return {
|
||||
kind: 'event_target_area',
|
||||
center: 'event_target',
|
||||
validTargets: 'enemies',
|
||||
radiusMeters,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: 'within_radius',
|
||||
population: mode === 'allies_within_radius' ? 'allies' : 'enemies',
|
||||
radiusMeters,
|
||||
};
|
||||
}
|
||||
|
||||
export type StatePoolThresholdComparison = {
|
||||
pool: string;
|
||||
operator: PredicateComparisonOperator;
|
||||
@@ -258,6 +298,7 @@ export const CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES = {
|
||||
'maxStacks',
|
||||
'targeting',
|
||||
'descriptionSlot',
|
||||
'probabilityPercent',
|
||||
],
|
||||
phase: ['on_event', 'after_event', 'on_enter', 'on_exit', 'while_active'],
|
||||
kind: [
|
||||
@@ -291,6 +332,25 @@ export const CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES = {
|
||||
continuous:
|
||||
'Valid only for restoration. The value is the total restoration, delivered at a constant rate across durationSeconds. Each activation creates an independent stream; overlapping streams add and overheal is lost when it occurs.',
|
||||
},
|
||||
probabilitySemantics:
|
||||
'probabilityPercent is an optional inclusive 0 through 100 percentage. In custom simulation, each eligible outcome rolls independently from the evaluation random stream. Maximum mode resolves every nonzero probability as successful.',
|
||||
targeting: {
|
||||
withinRadius: {
|
||||
kind: 'within_radius',
|
||||
population: ['allies', 'enemies'],
|
||||
radiusMeters: 'positive finite number',
|
||||
semantics:
|
||||
'Legacy radius metadata. It preserves authored range but does not select or fan out runtime targets.',
|
||||
},
|
||||
eventTargetArea: {
|
||||
kind: 'event_target_area',
|
||||
center: 'event_target',
|
||||
validTargets: 'enemies',
|
||||
radiusMeters: 'positive finite number',
|
||||
semantics:
|
||||
'Valid only for target-scoped direct damage in on_event or after_event phases on a weapon_hit clause. The triggering event must supply an authoritative event-target area sample. Every valid enemy at or inside the radius receives one equal damage emission, ordered by entity ID.',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
const phases = new Set<OutcomePhase>(
|
||||
CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES.phase,
|
||||
@@ -366,11 +426,14 @@ function isReference(value: unknown): value is ValueReference {
|
||||
Boolean(value.path.trim()) &&
|
||||
(value.timing === undefined ||
|
||||
value.timing === 'pre_event' ||
|
||||
value.timing === 'current') &&
|
||||
!(value.path === 'currentHealthPercent' && value.timing === 'pre_event')
|
||||
value.timing === 'current')
|
||||
);
|
||||
case 'equipment':
|
||||
return typeof value.path === 'string' && Boolean(value.path.trim());
|
||||
case 'assigned_ability':
|
||||
return (
|
||||
value.path === 'active' || value.path === 'remainingDurationSeconds'
|
||||
);
|
||||
case 'attribute':
|
||||
return (
|
||||
typeof value.attributeId === 'string' &&
|
||||
@@ -527,6 +590,17 @@ function isMutation(value: unknown): value is ClauseMutation {
|
||||
isExpression(value.value))
|
||||
);
|
||||
}
|
||||
if (value.kind === 'extend_duration') {
|
||||
return value.target === 'assigned_ability' && isExpression(value.seconds);
|
||||
}
|
||||
if (value.kind === 'start_cooldown_on_max') {
|
||||
return (
|
||||
typeof value.stackPool === 'string' &&
|
||||
Boolean(value.stackPool.trim()) &&
|
||||
typeof value.cooldownPool === 'string' &&
|
||||
Boolean(value.cooldownPool.trim())
|
||||
);
|
||||
}
|
||||
return (
|
||||
['add', 'set', 'consume', 'derive'].includes(value.kind) &&
|
||||
typeof value.pool === 'string' &&
|
||||
@@ -724,30 +798,21 @@ function parseOutcome(
|
||||
) {
|
||||
errors.push(`${path} description slot must be a positive integer.`);
|
||||
}
|
||||
if (value.targeting !== undefined) {
|
||||
if (
|
||||
!object(value.targeting) ||
|
||||
value.targeting.kind !== 'within_radius' ||
|
||||
!['allies', 'enemies'].includes(String(value.targeting.population)) ||
|
||||
!finite(value.targeting.radiusMeters) ||
|
||||
value.targeting.radiusMeters <= 0
|
||||
) {
|
||||
errors.push(`${path} has invalid radius targeting.`);
|
||||
} else if (
|
||||
value.targeting.population === 'allies' &&
|
||||
value.scope !== 'allies' &&
|
||||
value.scope !== 'self_and_allies'
|
||||
) {
|
||||
errors.push(
|
||||
`${path} ally radius targeting requires allies or self-and-allies scope.`,
|
||||
);
|
||||
} else if (
|
||||
value.targeting.population === 'enemies' &&
|
||||
value.scope !== 'target'
|
||||
) {
|
||||
errors.push(`${path} enemy radius targeting requires target scope.`);
|
||||
}
|
||||
if (
|
||||
value.probabilityPercent !== undefined &&
|
||||
(!finite(value.probabilityPercent) ||
|
||||
value.probabilityPercent < 0 ||
|
||||
value.probabilityPercent > 100)
|
||||
) {
|
||||
errors.push(`${path} probability must be from 0 through 100 percent.`);
|
||||
}
|
||||
const targetingError = mechanicOutcomeTargetingError({
|
||||
kind,
|
||||
scope: value.scope as MechanicOutcomeScope,
|
||||
phase: value.phase as OutcomePhase,
|
||||
targeting: value.targeting,
|
||||
});
|
||||
if (targetingError) errors.push(`${path} ${targetingError}`);
|
||||
if (!object(value.variants)) {
|
||||
errors.push(`${path} variants must be an object.`);
|
||||
return null;
|
||||
@@ -828,6 +893,22 @@ function parseClauses(
|
||||
if (candidate.weight !== undefined && !isExpression(candidate.weight)) {
|
||||
errors.push(`${path} has an invalid weight expression.`);
|
||||
}
|
||||
if (
|
||||
candidate.procChancePercent !== undefined &&
|
||||
(!finite(candidate.procChancePercent) ||
|
||||
candidate.procChancePercent < 0 ||
|
||||
candidate.procChancePercent > 100)
|
||||
) {
|
||||
errors.push(`${path} proc chance must be from 0 through 100 percent.`);
|
||||
}
|
||||
if (
|
||||
candidate.requiresAssignedAbilityActive !== undefined &&
|
||||
typeof candidate.requiresAssignedAbilityActive !== 'boolean'
|
||||
) {
|
||||
errors.push(
|
||||
`${path} assigned-ability activity requirement must be a boolean.`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
candidate.cooldownSeconds !== undefined &&
|
||||
(!finite(candidate.cooldownSeconds) || candidate.cooldownSeconds <= 0)
|
||||
@@ -902,6 +983,15 @@ function programsForDraft(
|
||||
? {}
|
||||
: { cooldownSeconds: clause.cooldownSeconds }),
|
||||
...(clause.weight === undefined ? {} : { weight: clause.weight }),
|
||||
...(clause.procChancePercent === undefined
|
||||
? {}
|
||||
: { procChancePercent: clause.procChancePercent }),
|
||||
...(clause.requiresAssignedAbilityActive === undefined
|
||||
? {}
|
||||
: {
|
||||
requiresAssignedAbilityActive:
|
||||
clause.requiresAssignedAbilityActive,
|
||||
}),
|
||||
mutations: clause.mutations,
|
||||
outcomes: clause.outcomes.map(
|
||||
({
|
||||
|
||||
@@ -200,6 +200,143 @@ describe('conditional-effect persistence adapter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves event-target area damage semantics on an outcome', () => {
|
||||
const result = adaptConditionalEffectProgram(
|
||||
{
|
||||
rule: rule(),
|
||||
statePools: [],
|
||||
clauses: [
|
||||
clause({
|
||||
trigger_id: null,
|
||||
event_kind: 'weapon_hit',
|
||||
state_pool_id: null,
|
||||
mutations_json: '[]',
|
||||
}),
|
||||
],
|
||||
outcomes: [
|
||||
outcome({
|
||||
application_kind: 'direct_damage',
|
||||
effect_scope: 'target',
|
||||
stat_attribute_id: null,
|
||||
application_phase: 'after_event',
|
||||
state_pool_id: null,
|
||||
targeting_json: JSON.stringify({
|
||||
kind: 'event_target_area',
|
||||
center: 'event_target',
|
||||
validTargets: 'enemies',
|
||||
radiusMeters: 5,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
triggers: [],
|
||||
},
|
||||
{ name: 'Critical Explosion', variantKey: 'I' },
|
||||
);
|
||||
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.program?.clauses[0].outcomes?.[0]).toMatchObject({
|
||||
kind: 'direct_damage',
|
||||
scope: 'target',
|
||||
phase: 'after_event',
|
||||
targeting: {
|
||||
kind: 'event_target_area',
|
||||
center: 'event_target',
|
||||
validTargets: 'enemies',
|
||||
radiusMeters: 5,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('adapts assigned-ability activity predicates for kill and assist events', () => {
|
||||
for (const eventKind of ['enemy_killed', 'assist'] as const) {
|
||||
const result = adaptConditionalEffectProgram(
|
||||
{
|
||||
rule: rule(),
|
||||
statePools: [],
|
||||
clauses: [
|
||||
clause({
|
||||
trigger_id: null,
|
||||
event_kind: eventKind,
|
||||
parameters_json: '{}',
|
||||
state_pool_id: null,
|
||||
mutations_json: '[]',
|
||||
conditions_json: JSON.stringify({
|
||||
kind: 'compare',
|
||||
left: {
|
||||
source: 'event',
|
||||
path: 'values.assignedAbilityActive',
|
||||
},
|
||||
operator: 'eq',
|
||||
right: true,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
outcomes: [outcome({ state_pool_id: null })],
|
||||
triggers: [],
|
||||
},
|
||||
{ variantKey: 'I' },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ status: 'READY', errors: [] });
|
||||
expect(result.program?.clauses[0]).toMatchObject({
|
||||
when: eventKind,
|
||||
if: {
|
||||
kind: 'compare',
|
||||
left: {
|
||||
source: 'event',
|
||||
path: 'values.assignedAbilityActive',
|
||||
},
|
||||
operator: 'eq',
|
||||
right: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves outcome probability metadata', () => {
|
||||
const result = adaptConditionalEffectProgram(
|
||||
{
|
||||
rule: rule(),
|
||||
statePools: [],
|
||||
clauses: [
|
||||
clause({
|
||||
trigger_id: null,
|
||||
event_kind: 'shot_fired',
|
||||
recipe_parameters_json: JSON.stringify({
|
||||
procChancePercent: 10,
|
||||
requiresAssignedAbilityActive: true,
|
||||
}),
|
||||
parameters_json: '{}',
|
||||
state_pool_id: null,
|
||||
mutations_json: '[]',
|
||||
}),
|
||||
],
|
||||
outcomes: [
|
||||
outcome({
|
||||
state_pool_id: null,
|
||||
application_kind: 'restore_ammo',
|
||||
effect_scope: 'self',
|
||||
stat_attribute_id: null,
|
||||
resource_kind: 'ammo',
|
||||
probability_percent: 29,
|
||||
}),
|
||||
],
|
||||
triggers: [],
|
||||
},
|
||||
{ variantKey: 'I' },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ status: 'READY', errors: [] });
|
||||
expect(result.program?.clauses[0].outcomes?.[0]).toMatchObject({
|
||||
probabilityPercent: 29,
|
||||
resource: 'ammo',
|
||||
});
|
||||
expect(result.program?.clauses[0]).toMatchObject({
|
||||
procChancePercent: 10,
|
||||
requiresAssignedAbilityActive: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('loads continuous restoration delivery metadata', () => {
|
||||
const result = adaptConditionalEffectProgram(
|
||||
{
|
||||
@@ -713,7 +850,7 @@ describe('conditional-effect persistence adapter', () => {
|
||||
left: {
|
||||
source: 'combat',
|
||||
path: 'currentHealthPercent',
|
||||
timing: 'current',
|
||||
timing: 'pre_event',
|
||||
},
|
||||
operator: 'lt',
|
||||
right: 25,
|
||||
|
||||
@@ -54,6 +54,7 @@ export type ConditionalEffectClauseRow = {
|
||||
event_kind?: DatabaseValue;
|
||||
recipe_key?: DatabaseValue;
|
||||
parameters_json: DatabaseValue;
|
||||
recipe_parameters_json?: DatabaseValue;
|
||||
conditions_json: DatabaseValue;
|
||||
reducer_json: DatabaseValue;
|
||||
weight_expression_json?: DatabaseValue;
|
||||
@@ -85,6 +86,7 @@ export type ConditionalEffectOutcomeRow = {
|
||||
delivery_mode?: DatabaseValue;
|
||||
delivery_duration_seconds?: DatabaseValue;
|
||||
targeting_json?: DatabaseValue;
|
||||
probability_percent?: DatabaseValue;
|
||||
value_scaling: DatabaseValue;
|
||||
unit: DatabaseValue;
|
||||
damage_basis: DatabaseValue;
|
||||
@@ -205,14 +207,16 @@ function isValueReference(value: unknown) {
|
||||
if (value.source === 'event' || value.source === 'equipment') {
|
||||
return typeof value.path === 'string' && Boolean(value.path.trim());
|
||||
}
|
||||
if (value.source === 'assigned_ability') {
|
||||
return value.path === 'active' || value.path === 'remainingDurationSeconds';
|
||||
}
|
||||
if (value.source === 'combat') {
|
||||
return (
|
||||
typeof value.path === 'string' &&
|
||||
Boolean(value.path.trim()) &&
|
||||
(value.timing === undefined ||
|
||||
value.timing === 'pre_event' ||
|
||||
value.timing === 'current') &&
|
||||
!(value.path === 'currentHealthPercent' && value.timing === 'pre_event')
|
||||
value.timing === 'current')
|
||||
);
|
||||
}
|
||||
if (value.source === 'attribute') {
|
||||
@@ -313,6 +317,19 @@ function isMutation(value: unknown): value is ClauseMutation {
|
||||
isNumericExpression(value.value))
|
||||
);
|
||||
}
|
||||
if (value.kind === 'extend_duration') {
|
||||
return (
|
||||
value.target === 'assigned_ability' && isNumericExpression(value.seconds)
|
||||
);
|
||||
}
|
||||
if (value.kind === 'start_cooldown_on_max') {
|
||||
return (
|
||||
typeof value.stackPool === 'string' &&
|
||||
Boolean(value.stackPool.trim()) &&
|
||||
typeof value.cooldownPool === 'string' &&
|
||||
Boolean(value.cooldownPool.trim())
|
||||
);
|
||||
}
|
||||
return (
|
||||
['add', 'set', 'consume', 'derive'].includes(value.kind) &&
|
||||
typeof value.pool === 'string' &&
|
||||
@@ -731,6 +748,17 @@ function parseOutcome(
|
||||
if (delivery === 'instant' && durationSeconds !== null) {
|
||||
errors.push(`${path} instant delivery cannot set a duration.`);
|
||||
}
|
||||
const probabilityPercent = number(row.probability_percent);
|
||||
if (
|
||||
row.probability_percent !== undefined &&
|
||||
row.probability_percent !== null &&
|
||||
row.probability_percent !== '' &&
|
||||
(probabilityPercent === null ||
|
||||
probabilityPercent < 0 ||
|
||||
probabilityPercent > 100)
|
||||
) {
|
||||
errors.push(`${path} probability must be from 0 through 100 percent.`);
|
||||
}
|
||||
let targeting: MechanicOutcome['targeting'] | undefined;
|
||||
if (
|
||||
row.targeting_json !== null &&
|
||||
@@ -766,6 +794,7 @@ function parseOutcome(
|
||||
? { delivery, durationSeconds }
|
||||
: {}),
|
||||
...(targeting ? { targeting } : {}),
|
||||
...(probabilityPercent === null ? {} : { probabilityPercent }),
|
||||
...(kind === 'attribute_modifier' && nullableText(row.stat_attribute_id)
|
||||
? { attributeId: text(row.stat_attribute_id) }
|
||||
: {}),
|
||||
@@ -903,7 +932,7 @@ export function adaptConditionalEffectProgram(
|
||||
continue;
|
||||
}
|
||||
const parametersJson = parseJson(
|
||||
row.parameters_json,
|
||||
row.recipe_parameters_json ?? row.parameters_json,
|
||||
`${path} parameters`,
|
||||
errors,
|
||||
);
|
||||
@@ -976,6 +1005,25 @@ export function adaptConditionalEffectProgram(
|
||||
}
|
||||
}
|
||||
const recipeKey = nullableText(row.recipe_key);
|
||||
const procChancePercent = parametersJson.value.procChancePercent;
|
||||
if (
|
||||
procChancePercent !== undefined &&
|
||||
(!finite(procChancePercent) ||
|
||||
procChancePercent < 0 ||
|
||||
procChancePercent > 100)
|
||||
) {
|
||||
errors.push(`${path} proc chance must be from 0 through 100 percent.`);
|
||||
}
|
||||
const requiresAssignedAbilityActive =
|
||||
parametersJson.value.requiresAssignedAbilityActive;
|
||||
if (
|
||||
requiresAssignedAbilityActive !== undefined &&
|
||||
typeof requiresAssignedAbilityActive !== 'boolean'
|
||||
) {
|
||||
errors.push(
|
||||
`${path} assigned-ability activity requirement must be a boolean.`,
|
||||
);
|
||||
}
|
||||
clauses.push({
|
||||
id,
|
||||
label: text(row.label) || text(row.clause_key) || undefined,
|
||||
@@ -989,6 +1037,10 @@ export function adaptConditionalEffectProgram(
|
||||
? {}
|
||||
: { cooldownSeconds: number(row.cooldown_seconds) as number }),
|
||||
...(weight === undefined ? {} : { weight }),
|
||||
...(typeof procChancePercent === 'number' ? { procChancePercent } : {}),
|
||||
...(typeof requiresAssignedAbilityActive === 'boolean'
|
||||
? { requiresAssignedAbilityActive }
|
||||
: {}),
|
||||
mutations: mutationsJson.value,
|
||||
...(clauseOutcomes.length ? { outcomes: clauseOutcomes } : {}),
|
||||
});
|
||||
|
||||
@@ -19,6 +19,276 @@ const custom = (
|
||||
inputs,
|
||||
});
|
||||
|
||||
describe('deterministic mechanic probabilities and assigned abilities', () => {
|
||||
it('rolls outcome probability independently with a reproducible seed and preserves a refreshed window', () => {
|
||||
const program: MechanicProgram = {
|
||||
id: 'ammo-refill',
|
||||
name: 'Ammo Refill',
|
||||
pools: [
|
||||
{
|
||||
key: 'tactical-link-window',
|
||||
label: 'Tactical Link window',
|
||||
scope: 'agent',
|
||||
initial: 0,
|
||||
min: 0,
|
||||
max: 1,
|
||||
expiresAfterSeconds: 6,
|
||||
},
|
||||
],
|
||||
clauses: [
|
||||
{
|
||||
id: 'start-window',
|
||||
when: 'signature_ability_deployed',
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [{ kind: 'set', pool: 'tactical-link-window', value: 1 }],
|
||||
},
|
||||
{
|
||||
id: 'refresh-window',
|
||||
when: 'enemy_killed',
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [{ kind: 'set', pool: 'tactical-link-window', value: 1 }],
|
||||
},
|
||||
{
|
||||
id: 'recover-ammo',
|
||||
when: 'shot_fired',
|
||||
if: {
|
||||
kind: 'compare',
|
||||
left: { source: 'pool', pool: 'tactical-link-window' },
|
||||
operator: 'eq',
|
||||
right: 1,
|
||||
},
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [],
|
||||
outcomes: [
|
||||
{
|
||||
id: 'recover-one-ammo',
|
||||
label: 'Recover one ammo',
|
||||
phase: 'after_event',
|
||||
kind: 'restoration',
|
||||
scope: 'self',
|
||||
basis: 'flat',
|
||||
resource: 'ammo',
|
||||
value: 1,
|
||||
probabilityPercent: 29,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const events = [
|
||||
{
|
||||
kind: 'signature_ability_deployed' as const,
|
||||
at: 0,
|
||||
values: { assignedAbilityDurationSeconds: 6 },
|
||||
},
|
||||
{ kind: 'shot_fired' as const, at: 1 },
|
||||
{ kind: 'shot_fired' as const, at: 2 },
|
||||
{ kind: 'enemy_killed' as const, at: 5 },
|
||||
{ kind: 'shot_fired' as const, at: 6 },
|
||||
{ kind: 'shot_fired' as const, at: 11 },
|
||||
];
|
||||
const first = evaluateMechanicProgram(
|
||||
program,
|
||||
events,
|
||||
{ mode: 'custom', encounterSeconds: 12, randomSeed: 1 },
|
||||
{ maxHealth: 100, currentHealth: 100, maxAmmo: 20, currentAmmo: 5 },
|
||||
);
|
||||
const second = evaluateMechanicProgram(
|
||||
program,
|
||||
events,
|
||||
{ mode: 'custom', encounterSeconds: 12, randomSeed: 1 },
|
||||
{ maxHealth: 100, currentHealth: 100, maxAmmo: 20, currentAmmo: 5 },
|
||||
);
|
||||
|
||||
expect(first.status).toBe('OK');
|
||||
expect(first.randomSeed).toBe(1);
|
||||
expect(first.outcomes.map((outcome) => outcome.at)).toEqual([2]);
|
||||
expect(first.outcomes[0]).toMatchObject({
|
||||
probabilityPercent: 29,
|
||||
probabilityRoll: expect.any(Number),
|
||||
appliedValue: 1,
|
||||
});
|
||||
expect(first.finalCombat?.currentAmmo).toBe(6);
|
||||
expect(second.outcomes).toEqual(first.outcomes);
|
||||
});
|
||||
|
||||
it('gates a proc before mutations, extends a live assigned ability, and starts cooldown on the eighth stack', () => {
|
||||
const program: MechanicProgram = {
|
||||
id: 'tactical-loop',
|
||||
name: 'Tactical Loop',
|
||||
pools: [
|
||||
{
|
||||
key: 'extension-stacks',
|
||||
label: 'Extension stacks',
|
||||
scope: 'agent',
|
||||
initial: 7,
|
||||
min: 0,
|
||||
max: 8,
|
||||
},
|
||||
{
|
||||
key: 'extension-cooldown',
|
||||
label: 'Extension cooldown',
|
||||
scope: 'agent',
|
||||
initial: 0,
|
||||
min: 0,
|
||||
max: 1,
|
||||
expiresAfterSeconds: 15,
|
||||
},
|
||||
],
|
||||
clauses: [
|
||||
{
|
||||
id: 'extend-tactical-link',
|
||||
when: 'skill_hit',
|
||||
if: {
|
||||
kind: 'compare',
|
||||
left: { source: 'pool', pool: 'extension-cooldown' },
|
||||
operator: 'eq',
|
||||
right: 0,
|
||||
},
|
||||
requiresAssignedAbilityActive: true,
|
||||
procChancePercent: 10,
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [
|
||||
{ kind: 'add', pool: 'extension-stacks', value: 1 },
|
||||
{
|
||||
kind: 'extend_duration',
|
||||
target: 'assigned_ability',
|
||||
seconds: 0.3,
|
||||
},
|
||||
{
|
||||
kind: 'start_cooldown_on_max',
|
||||
stackPool: 'extension-stacks',
|
||||
cooldownPool: 'extension-cooldown',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const success = evaluateMechanicProgram(
|
||||
program,
|
||||
[
|
||||
{
|
||||
kind: 'signature_ability_deployed',
|
||||
at: 0,
|
||||
values: { assignedAbilityDurationSeconds: 6 },
|
||||
},
|
||||
{ kind: 'skill_hit', at: 5.9 },
|
||||
{ kind: 'skill_hit', at: 6.1 },
|
||||
],
|
||||
{ mode: 'custom', encounterSeconds: 7, randomSeed: 7 },
|
||||
);
|
||||
const failed = evaluateMechanicProgram(
|
||||
program,
|
||||
[
|
||||
{
|
||||
kind: 'signature_ability_deployed',
|
||||
at: 0,
|
||||
values: { assignedAbilityDurationSeconds: 6 },
|
||||
},
|
||||
{ kind: 'skill_hit', at: 5.9 },
|
||||
],
|
||||
{ mode: 'custom', encounterSeconds: 7, randomSeed: 1 },
|
||||
);
|
||||
|
||||
expect(success.status).toBe('OK');
|
||||
expect(success.emissions).toHaveLength(1);
|
||||
const extension = success.emissions[0].mutations.find(
|
||||
(mutation) => mutation.kind === 'extend_duration',
|
||||
);
|
||||
expect(extension).toMatchObject({ kind: 'extend_duration' });
|
||||
expect(extension?.before).toBeCloseTo(0.1);
|
||||
expect(extension?.after).toBeCloseTo(0.4);
|
||||
expect(success.emissions[0].mutations).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: 'start_cooldown_on_max',
|
||||
pool: 'extension-cooldown',
|
||||
after: 1,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(success.finalState).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
pool: 'extension-stacks',
|
||||
value: 8,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
pool: 'extension-cooldown',
|
||||
value: 1,
|
||||
expiresAt: 20.9,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(failed.emissions).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not extend an assigned ability at its exact expiry and rejects invalid chance data', () => {
|
||||
const boundaryProgram: MechanicProgram = {
|
||||
id: 'exact-expiry',
|
||||
name: 'Exact expiry',
|
||||
clauses: [
|
||||
{
|
||||
id: 'extend',
|
||||
when: 'skill_hit',
|
||||
requiresAssignedAbilityActive: true,
|
||||
procChancePercent: 100,
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [
|
||||
{
|
||||
kind: 'extend_duration',
|
||||
target: 'assigned_ability',
|
||||
seconds: 0.3,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(
|
||||
evaluateMechanicProgram(
|
||||
boundaryProgram,
|
||||
[
|
||||
{
|
||||
kind: 'signature_ability_deployed',
|
||||
at: 0,
|
||||
values: { assignedAbilityDurationSeconds: 1 },
|
||||
},
|
||||
{ kind: 'skill_hit', at: 1 },
|
||||
],
|
||||
{ mode: 'custom', encounterSeconds: 2, randomSeed: 7 },
|
||||
).emissions,
|
||||
).toEqual([]);
|
||||
|
||||
const invalid = structuredClone(boundaryProgram);
|
||||
invalid.clauses[0].procChancePercent = 101;
|
||||
expect(validateMechanicProgram(invalid)).toMatchObject({
|
||||
ok: false,
|
||||
errors: [
|
||||
expect.stringContaining('proc chance must be from 0 through 100'),
|
||||
],
|
||||
});
|
||||
const invalidOutcome = structuredClone(boundaryProgram);
|
||||
invalidOutcome.clauses[0].outcomes = [
|
||||
{
|
||||
id: 'bad-chance',
|
||||
label: 'Bad chance',
|
||||
phase: 'after_event',
|
||||
kind: 'marker',
|
||||
scope: 'self',
|
||||
basis: 'flat',
|
||||
value: 1,
|
||||
probabilityPercent: -1,
|
||||
},
|
||||
];
|
||||
expect(validateMechanicProgram(invalidOutcome)).toMatchObject({
|
||||
ok: false,
|
||||
errors: [
|
||||
expect.stringContaining('probability must be from 0 through 100'),
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('structured mechanic runtime', () => {
|
||||
it('advertises the authoritative weapon-hit target and headshot fields', () => {
|
||||
expect(MECHANIC_EVENT_REGISTRY.weapon_hit).toMatchObject({
|
||||
@@ -32,6 +302,11 @@ describe('structured mechanic runtime', () => {
|
||||
},
|
||||
{ path: 'values.headshot', label: 'Headshot', type: 'boolean' },
|
||||
{ path: 'values.critical', label: 'Critical hit', type: 'boolean' },
|
||||
{
|
||||
path: 'values.aiming',
|
||||
label: 'Aiming down sights',
|
||||
type: 'boolean',
|
||||
},
|
||||
{
|
||||
path: 'values.behindCover',
|
||||
label: 'Agent behind cover',
|
||||
@@ -305,6 +580,161 @@ describe('structured mechanic runtime', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('matches kills and assists only while the assigned ability is active', () => {
|
||||
const weaponCriticalHitChance = 'weapon-critical-hit-chance';
|
||||
const activeOutcome = {
|
||||
id: 'tactical-link-critical-chance',
|
||||
label: 'Weapon Critical Hit Chance',
|
||||
phase: 'while_active' as const,
|
||||
kind: 'attribute_modifier' as const,
|
||||
scope: 'self' as const,
|
||||
basis: 'flat' as const,
|
||||
value: 10.2,
|
||||
attributeId: weaponCriticalHitChance,
|
||||
statePool: 'tactical-link-kill-bonus',
|
||||
};
|
||||
const activePredicate = {
|
||||
kind: 'compare' as const,
|
||||
left: {
|
||||
source: 'event' as const,
|
||||
path: 'values.assignedAbilityActive',
|
||||
},
|
||||
operator: 'eq' as const,
|
||||
right: true,
|
||||
};
|
||||
const program: MechanicProgram = {
|
||||
id: 'tactical-link-kill-bonus',
|
||||
name: 'Tactical Link Kill Bonus',
|
||||
pools: [
|
||||
{
|
||||
key: 'tactical-link-kill-bonus',
|
||||
label: 'Tactical Link kill bonus',
|
||||
scope: 'agent',
|
||||
initial: 0,
|
||||
min: 0,
|
||||
max: 1,
|
||||
expiresAfterSeconds: 10,
|
||||
},
|
||||
],
|
||||
clauses: [
|
||||
{
|
||||
id: 'kill-during-tactical-link',
|
||||
when: 'enemy_killed',
|
||||
if: activePredicate,
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [
|
||||
{ kind: 'set', pool: 'tactical-link-kill-bonus', value: 1 },
|
||||
],
|
||||
outcomes: [activeOutcome],
|
||||
},
|
||||
{
|
||||
id: 'assist-during-tactical-link',
|
||||
when: 'assist',
|
||||
if: activePredicate,
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [
|
||||
{ kind: 'set', pool: 'tactical-link-kill-bonus', value: 1 },
|
||||
],
|
||||
outcomes: [{ ...activeOutcome }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(validateMechanicProgram(program)).toMatchObject({ ok: true });
|
||||
const result = evaluateMechanicProgram(
|
||||
program,
|
||||
[
|
||||
{
|
||||
kind: 'enemy_killed',
|
||||
at: 0,
|
||||
values: { assignedAbilityActive: true },
|
||||
},
|
||||
{
|
||||
kind: 'assist',
|
||||
at: 4,
|
||||
values: { assignedAbilityActive: false },
|
||||
},
|
||||
{ kind: 'enemy_killed', at: 6 },
|
||||
{
|
||||
kind: 'assist',
|
||||
at: 10,
|
||||
values: { assignedAbilityActive: true },
|
||||
},
|
||||
],
|
||||
custom(19),
|
||||
{
|
||||
maxHealth: 1_000,
|
||||
currentHealth: 1_000,
|
||||
attributes: { [weaponCriticalHitChance]: 0 },
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).toBe('OK');
|
||||
expect(result.outcomes.map((outcome) => outcome.at)).toEqual([0, 10]);
|
||||
expect(result.finalState).toContainEqual(
|
||||
expect.objectContaining({
|
||||
pool: 'tactical-link-kill-bonus',
|
||||
value: 1,
|
||||
expiresAt: 20,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects invalid assigned-ability activity predicates and event values', () => {
|
||||
const program: MechanicProgram = {
|
||||
id: 'assigned-ability-activity',
|
||||
name: 'Assigned ability activity',
|
||||
clauses: [
|
||||
{
|
||||
id: 'kill',
|
||||
when: 'enemy_killed',
|
||||
if: {
|
||||
kind: 'compare',
|
||||
left: {
|
||||
source: 'event',
|
||||
path: 'values.assignedAbilityActive',
|
||||
},
|
||||
operator: 'eq',
|
||||
right: true,
|
||||
},
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
const invalidPredicate = structuredClone(program);
|
||||
invalidPredicate.clauses[0].if = {
|
||||
kind: 'compare',
|
||||
left: { source: 'event', path: 'values.assignedAbilityActive' },
|
||||
operator: 'eq',
|
||||
right: 'yes',
|
||||
};
|
||||
expect(validateMechanicProgram(invalidPredicate)).toMatchObject({
|
||||
ok: false,
|
||||
errors: [
|
||||
expect.stringContaining('Assigned ability active with a boolean value'),
|
||||
],
|
||||
});
|
||||
expect(
|
||||
evaluateMechanicProgram(
|
||||
program,
|
||||
[
|
||||
{
|
||||
kind: 'enemy_killed',
|
||||
at: 0,
|
||||
values: { assignedAbilityActive: 'yes' },
|
||||
} as unknown as MechanicEvent,
|
||||
],
|
||||
custom(1),
|
||||
),
|
||||
).toMatchObject({
|
||||
status: 'INVALID',
|
||||
errors: [
|
||||
expect.stringContaining('Assigned ability active must be a boolean'),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('executes imported talent trigger families as first-class events', () => {
|
||||
const eventKinds: MechanicEventKind[] = [
|
||||
'ally_downed',
|
||||
@@ -2404,13 +2834,13 @@ describe('mechanic validation and pseudo-script', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects pre-event timing for the derived current Health percentage', () => {
|
||||
const invalid: MechanicProgram = {
|
||||
id: 'invalid-health-percent',
|
||||
name: 'Invalid Health Percent',
|
||||
it('evaluates pre-event timing for the derived current Health percentage', () => {
|
||||
const program: MechanicProgram = {
|
||||
id: 'pre-event-health-percent',
|
||||
name: 'Pre-event Health Percent',
|
||||
clauses: [
|
||||
{
|
||||
id: 'invalid',
|
||||
id: 'full-health-before-event',
|
||||
when: 'tick',
|
||||
if: {
|
||||
kind: 'compare',
|
||||
@@ -2419,25 +2849,32 @@ describe('mechanic validation and pseudo-script', () => {
|
||||
path: 'currentHealthPercent',
|
||||
timing: 'pre_event',
|
||||
},
|
||||
operator: 'lt',
|
||||
right: 50,
|
||||
operator: 'gte',
|
||||
right: 100,
|
||||
},
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [],
|
||||
outcomes: [
|
||||
{
|
||||
id: 'matched',
|
||||
label: 'Matched',
|
||||
phase: 'on_event',
|
||||
kind: 'marker',
|
||||
scope: 'self',
|
||||
basis: 'flat',
|
||||
value: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = validateMechanicProgram(invalid);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.errors).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining(
|
||||
'current Health percentage must use current timing',
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
expect(validateMechanicProgram(program)).toMatchObject({ ok: true });
|
||||
expect(
|
||||
evaluateMechanicProgram(program, [{ kind: 'tick', at: 0 }], custom(1), {
|
||||
maxHealth: 100,
|
||||
currentHealth: 100,
|
||||
}).outcomes.map((outcome) => outcome.outcomeId),
|
||||
).toEqual(['matched']);
|
||||
});
|
||||
|
||||
it('formats nested conditions and state operations deterministically', () => {
|
||||
@@ -2550,4 +2987,287 @@ describe('mechanic validation and pseudo-script', () => {
|
||||
expect(result.script).toContain('TO ALLIES WITHIN 40m');
|
||||
expect(result.script).toContain('TO ENEMIES WITHIN 20m');
|
||||
});
|
||||
|
||||
it('applies aimed critical-hit area damage once to each valid enemy at or inside the radius', () => {
|
||||
const program: MechanicProgram = {
|
||||
id: 'aimed-critical-explosion',
|
||||
name: 'Aimed Critical Explosion',
|
||||
clauses: [
|
||||
{
|
||||
id: 'aimed-critical-hit',
|
||||
when: 'weapon_hit',
|
||||
if: {
|
||||
kind: 'all',
|
||||
predicates: [
|
||||
{
|
||||
kind: 'compare',
|
||||
left: { source: 'event', path: 'values.critical' },
|
||||
operator: 'eq',
|
||||
right: true,
|
||||
},
|
||||
{
|
||||
kind: 'compare',
|
||||
left: { source: 'event', path: 'values.aiming' },
|
||||
operator: 'eq',
|
||||
right: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [],
|
||||
outcomes: [
|
||||
{
|
||||
id: 'explosion',
|
||||
label: 'Explosion',
|
||||
phase: 'after_event',
|
||||
kind: 'direct_damage',
|
||||
scope: 'target',
|
||||
basis: 'flat',
|
||||
value: {
|
||||
op: 'multiply',
|
||||
left: 20,
|
||||
right: {
|
||||
ref: { source: 'attribute', attributeId: 'firepower' },
|
||||
},
|
||||
},
|
||||
targeting: {
|
||||
kind: 'event_target_area',
|
||||
center: 'event_target',
|
||||
validTargets: 'enemies',
|
||||
radiusMeters: 5,
|
||||
},
|
||||
probabilityPercent: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = evaluateMechanicProgram(
|
||||
program,
|
||||
[
|
||||
{
|
||||
kind: 'weapon_hit',
|
||||
at: 1,
|
||||
targetId: 'enemy-b',
|
||||
values: { critical: true, aiming: true },
|
||||
areaTargetSample: {
|
||||
center: 'event_target',
|
||||
validTargets: [
|
||||
{
|
||||
entityId: 'enemy-c',
|
||||
population: 'enemy',
|
||||
distanceMeters: 5.001,
|
||||
},
|
||||
{ entityId: 'enemy-b', population: 'enemy', distanceMeters: 0 },
|
||||
{ entityId: 'ally-a', population: 'ally', distanceMeters: 2 },
|
||||
{ entityId: 'enemy-a', population: 'enemy', distanceMeters: 5 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
custom(5),
|
||||
{ maxHealth: 1000, currentHealth: 1000, attributes: { firepower: 100 } },
|
||||
);
|
||||
|
||||
expect(result.status).toBe('OK');
|
||||
expect(result.outcomes.map((outcome) => outcome.targetId)).toEqual([
|
||||
'enemy-a',
|
||||
'enemy-b',
|
||||
]);
|
||||
expect(result.outcomes.map((outcome) => outcome.appliedValue)).toEqual([
|
||||
2000, 2000,
|
||||
]);
|
||||
expect(
|
||||
new Set(result.outcomes.map((outcome) => outcome.probabilityRoll)).size,
|
||||
).toBe(1);
|
||||
expect(result.outcomes[0].probabilityRoll).not.toBeNull();
|
||||
expect(result.script).toContain(
|
||||
'EXPLOSION CENTERED ON EVENT TARGET; APPLY TO VALID ENEMIES WITHIN 5m',
|
||||
);
|
||||
const maximum = evaluateMechanicProgram(
|
||||
program,
|
||||
[
|
||||
{
|
||||
kind: 'weapon_hit',
|
||||
at: 1,
|
||||
targetId: 'enemy-b',
|
||||
values: { critical: true, aiming: true },
|
||||
areaTargetSample: {
|
||||
center: 'event_target',
|
||||
validTargets: [
|
||||
{ entityId: 'enemy-b', population: 'enemy', distanceMeters: 0 },
|
||||
{ entityId: 'enemy-a', population: 'enemy', distanceMeters: 5 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
{ mode: 'maximum', encounterSeconds: 5 },
|
||||
{ maxHealth: 1000, currentHealth: 1000, attributes: { firepower: 100 } },
|
||||
);
|
||||
expect(maximum.outcomes.map((outcome) => outcome.targetId)).toEqual([
|
||||
'enemy-a',
|
||||
'enemy-b',
|
||||
]);
|
||||
expect(
|
||||
maximum.outcomes.every((outcome) => outcome.probabilityRoll === 0),
|
||||
).toBe(true);
|
||||
|
||||
const skillProgram: MechanicProgram = {
|
||||
...program,
|
||||
id: 'skill-hit-explosion',
|
||||
clauses: program.clauses.map((clause) => ({
|
||||
...clause,
|
||||
when: 'skill_hit',
|
||||
if: undefined,
|
||||
})),
|
||||
};
|
||||
expect(validateMechanicProgram(skillProgram)).toMatchObject({ ok: true });
|
||||
expect(
|
||||
evaluateMechanicProgram(
|
||||
skillProgram,
|
||||
[
|
||||
{
|
||||
kind: 'skill_hit',
|
||||
at: 1,
|
||||
targetId: 'enemy-b',
|
||||
areaTargetSample: {
|
||||
center: 'event_target',
|
||||
validTargets: [
|
||||
{ entityId: 'enemy-b', population: 'enemy', distanceMeters: 0 },
|
||||
{ entityId: 'enemy-a', population: 'enemy', distanceMeters: 5 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
custom(5),
|
||||
{
|
||||
maxHealth: 1000,
|
||||
currentHealth: 1000,
|
||||
attributes: { firepower: 100 },
|
||||
},
|
||||
).outcomes.map((outcome) => outcome.targetId),
|
||||
).toEqual(['enemy-a', 'enemy-b']);
|
||||
});
|
||||
|
||||
it('requires authoritative area evidence only when an area outcome activates', () => {
|
||||
const program: MechanicProgram = {
|
||||
id: 'area-evidence',
|
||||
name: 'Area Evidence',
|
||||
clauses: [
|
||||
{
|
||||
id: 'critical-hit',
|
||||
when: 'weapon_hit',
|
||||
if: {
|
||||
kind: 'compare',
|
||||
left: { source: 'event', path: 'values.critical' },
|
||||
operator: 'eq',
|
||||
right: true,
|
||||
},
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [],
|
||||
outcomes: [
|
||||
{
|
||||
id: 'explosion',
|
||||
label: 'Explosion',
|
||||
phase: 'after_event',
|
||||
kind: 'direct_damage',
|
||||
scope: 'target',
|
||||
basis: 'flat',
|
||||
value: 1,
|
||||
targeting: {
|
||||
kind: 'event_target_area',
|
||||
center: 'event_target',
|
||||
validTargets: 'enemies',
|
||||
radiusMeters: 5,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(
|
||||
evaluateMechanicProgram(
|
||||
program,
|
||||
[
|
||||
{
|
||||
kind: 'weapon_hit',
|
||||
at: 1,
|
||||
targetId: 'enemy',
|
||||
values: { critical: false },
|
||||
},
|
||||
],
|
||||
custom(5),
|
||||
),
|
||||
).toMatchObject({ status: 'OK', outcomes: [] });
|
||||
expect(
|
||||
evaluateMechanicProgram(
|
||||
program,
|
||||
[
|
||||
{
|
||||
kind: 'weapon_hit',
|
||||
at: 1,
|
||||
targetId: 'enemy',
|
||||
values: { critical: true },
|
||||
},
|
||||
],
|
||||
custom(5),
|
||||
),
|
||||
).toMatchObject({
|
||||
status: 'INVALID',
|
||||
errors: [
|
||||
expect.stringContaining('authoritative event-target area sample'),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects malformed and duplicate area-target samples', () => {
|
||||
const result = evaluateMechanicProgram(
|
||||
{ id: 'sample-validation', name: 'Sample Validation', clauses: [] },
|
||||
[
|
||||
{
|
||||
kind: 'weapon_hit',
|
||||
at: 1,
|
||||
targetId: 'enemy',
|
||||
areaTargetSample: {
|
||||
center: 'event_target',
|
||||
validTargets: [
|
||||
{ entityId: 'enemy', population: 'enemy', distanceMeters: 0 },
|
||||
{ entityId: 'enemy', population: 'enemy', distanceMeters: 1 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
custom(5),
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
status: 'INVALID',
|
||||
errors: [expect.stringContaining('duplicates entity "enemy"')],
|
||||
});
|
||||
|
||||
const oversizedTargets = Array.from({ length: 257 }, (_, index) => ({
|
||||
entityId: index === 0 ? 'enemy' : `enemy-${index}`,
|
||||
population: 'enemy' as const,
|
||||
distanceMeters: index === 0 ? 0 : 1,
|
||||
}));
|
||||
expect(
|
||||
evaluateMechanicProgram(
|
||||
{ id: 'sample-bound', name: 'Sample Bound', clauses: [] },
|
||||
[
|
||||
{
|
||||
kind: 'weapon_hit',
|
||||
at: 1,
|
||||
targetId: 'enemy',
|
||||
areaTargetSample: {
|
||||
center: 'event_target',
|
||||
validTargets: oversizedTargets,
|
||||
},
|
||||
},
|
||||
],
|
||||
custom(5),
|
||||
),
|
||||
).toMatchObject({
|
||||
status: 'INVALID',
|
||||
errors: [expect.stringContaining('cannot contain more than 256')],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -172,6 +172,10 @@
|
||||
<a class="discord-link-button" class:interaction-pending={discordLinkPending} aria-busy={discordLinkPending} href="/auth/discord?mode=link" onclick={() => discordLinkPending = true}><img src="/discord-symbol.svg" alt="" aria-hidden="true"/><span>Link Discord</span></a>
|
||||
{/if}
|
||||
{/if}
|
||||
<div class="security-summary" aria-label="Multi-factor authentication">
|
||||
<div><p class="eyebrow">ACCOUNT HARDENING</p><h2>MULTI-FACTOR AUTHENTICATION</h2><small>{account.mfa?.enabled ? 'Authenticator active.' : 'Optional for standard accounts.'}</small></div>
|
||||
<a class="secondary security-link" href="/account/security">REVIEW SECURITY →</a>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="account-panel" role="tabpanel">
|
||||
@@ -183,6 +187,6 @@
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.owner-account-control{width:100%;min-width:0;padding:12px}.current-avatar{display:grid;grid-template-columns:58px minmax(0,1fr) auto;align-items:center;gap:10px;padding:8px;border:1px solid var(--shd-border);background:#0d1011}.current-avatar>img{width:58px;height:58px;border:1px solid var(--shd-orange);border-radius:50%;object-fit:cover}.current-avatar>div{min-width:0;display:grid;gap:3px}.current-avatar small{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.current-avatar strong{overflow:hidden;color:var(--shd-text);font-size:var(--shd-type-supporting);text-overflow:ellipsis;white-space:nowrap}.avatar-edit-trigger{min-height:40px;padding:0 10px}.avatar-editor{display:grid;gap:9px;margin-top:9px;padding-top:9px;border-top:1px solid var(--shd-border)}.account-section-heading{display:flex;align-items:start;justify-content:space-between;gap:8px}.account-section-heading h2{margin-bottom:0}.owner-account-control :global(.image-drop-input){grid-template-columns:58px minmax(0,1fr)}.owner-account-control :global(.image-upload-actions){grid-column:1/-1;grid-template-columns:1fr}.discord-link-button{width:100%;min-height:44px;display:flex;align-items:center;justify-content:center;gap:9px;border:1px solid #6571dc;background:#171a32;color:#fff}.discord-link-button img{width:22px}.provider-unlink-confirm{grid-column:1/-1;display:grid;gap:8px;margin-top:8px;padding-top:8px;border-top:1px solid var(--shd-border)}.provider-unlink-confirm>div{display:grid;grid-template-columns:1fr 1fr;gap:6px}.provider-unlink-confirm button{width:100%;min-height:40px}
|
||||
@media(max-width:650px){.current-avatar{grid-template-columns:52px minmax(0,1fr)}.current-avatar>img{width:52px;height:52px}.avatar-edit-trigger{grid-column:1/-1;width:100%;min-height:44px}}
|
||||
.owner-account-control{width:100%;min-width:0;padding:12px}.current-avatar{display:grid;grid-template-columns:58px minmax(0,1fr) auto;align-items:center;gap:10px;padding:8px;border:1px solid var(--shd-border);background:#0d1011}.current-avatar>img{width:58px;height:58px;border:1px solid var(--shd-orange);border-radius:50%;object-fit:cover}.current-avatar>div{min-width:0;display:grid;gap:3px}.current-avatar small{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.current-avatar strong{overflow:hidden;color:var(--shd-text);font-size:var(--shd-type-supporting);text-overflow:ellipsis;white-space:nowrap}.avatar-edit-trigger{min-height:40px;padding:0 10px}.avatar-editor{display:grid;gap:9px;margin-top:9px;padding-top:9px;border-top:1px solid var(--shd-border)}.account-section-heading{display:flex;align-items:start;justify-content:space-between;gap:8px}.account-section-heading h2{margin-bottom:0}.owner-account-control :global(.image-drop-input){grid-template-columns:58px minmax(0,1fr)}.owner-account-control :global(.image-upload-actions){grid-column:1/-1;grid-template-columns:1fr}.discord-link-button{width:100%;min-height:44px;display:flex;align-items:center;justify-content:center;gap:9px;border:1px solid #6571dc;background:#171a32;color:#fff}.discord-link-button img{width:22px}.provider-unlink-confirm{grid-column:1/-1;display:grid;gap:8px;margin-top:8px;padding-top:8px;border-top:1px solid var(--shd-border)}.provider-unlink-confirm>div{display:grid;grid-template-columns:1fr 1fr;gap:6px}.provider-unlink-confirm button{width:100%;min-height:40px}.security-summary{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-top:18px;padding:14px 0;border-top:1px solid var(--shd-border)}.security-summary h2{margin:2px 0 4px}.security-summary small{color:var(--shd-text-muted)}.security-link{white-space:nowrap;text-decoration:none}
|
||||
@media(max-width:650px){.current-avatar{grid-template-columns:52px minmax(0,1fr)}.current-avatar>img{width:52px;height:52px}.avatar-edit-trigger{grid-column:1/-1;width:100%;min-height:44px}.security-summary{align-items:stretch;flex-direction:column}.security-link{width:100%;text-align:center}}
|
||||
</style>
|
||||
|
||||
@@ -206,7 +206,7 @@
|
||||
<span><small>COMMUNITY NETWORK</small>JOIN SHD DISCORD ↗</span>
|
||||
</a>
|
||||
<nav class="footer-nav" aria-label="Support and legal">
|
||||
<a href="/contact">CONTACT US</a><a href="/dmca">DMCA & COPYRIGHT</a><a href="/terms">TERMS & PRIVACY</a><a href="/terms#cookies">COOKIE POLICY</a><a href="/terms#retention">DATA RETENTION</a>
|
||||
<a href="/contact">CONTACT US</a><a href="https://buymeacoffee.com/shd.build" target="_blank" rel="noreferrer">BUY ME A COFFEE ↗</a><a href="/dmca">DMCA & COPYRIGHT</a><a href="/terms">TERMS & PRIVACY</a><a href="/terms#cookies">COOKIE POLICY</a><a href="/terms#retention">DATA RETENTION</a>
|
||||
</nav>
|
||||
</div>
|
||||
<p class="fan-disclaimer">UNOFFICIAL FAN SITE · NOT AFFILIATED WITH OR ENDORSED BY UBISOFT. TOM CLANCY’S THE DIVISION, THE DIVISION RESURGENCE, UBISOFT, AND RELATED INTELLECTUAL PROPERTY ARE OWNED BY UBISOFT AND/OR ITS LICENSORS.</p>
|
||||
|
||||
@@ -23,7 +23,9 @@
|
||||
let coverUploading = $state(false);
|
||||
let initialized = $state(false);
|
||||
let lastContent = '';
|
||||
let saveSequence = 0;
|
||||
let saveTimer: number | undefined;
|
||||
let saveInFlight = false;
|
||||
let saveQueued = false;
|
||||
const groupedTags = $derived.by(() => {
|
||||
const groups: Record<string, ComposerTag[]> = {};
|
||||
for (const tag of data.tags as ComposerTag[]) (groups[tag.group] ??= []).push(tag);
|
||||
@@ -35,6 +37,40 @@
|
||||
coverImageId, coverAlt, coverCaption, version };
|
||||
}
|
||||
|
||||
function scheduleSave() {
|
||||
if (saveTimer !== undefined) window.clearTimeout(saveTimer);
|
||||
saveTimer = window.setTimeout(() => {
|
||||
saveTimer = undefined;
|
||||
void saveDraft();
|
||||
}, 900);
|
||||
}
|
||||
|
||||
async function saveDraft() {
|
||||
if (saveInFlight || !saveQueued) return;
|
||||
saveInFlight = true;
|
||||
saveQueued = false;
|
||||
const draft = { ...payload(), tagIds: [...selectedTags] };
|
||||
try {
|
||||
const response = await fetch('/api/articles/draft', {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(draft)
|
||||
});
|
||||
const result = await response.json();
|
||||
if (response.status === 409) {
|
||||
saveState = 'conflict';
|
||||
return;
|
||||
}
|
||||
if (!response.ok) throw new Error(result.message || 'Draft save failed.');
|
||||
version = result.version;
|
||||
saveState = saveQueued ? 'saving' : 'saved';
|
||||
} catch (error) {
|
||||
saveState = 'error';
|
||||
toast.error('Draft not saved', error instanceof Error ? error.message : 'Try again.');
|
||||
} finally {
|
||||
saveInFlight = false;
|
||||
if (saveQueued && saveState !== 'conflict') scheduleSave();
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const content = JSON.stringify({ title, deck, document, selectedTags, channel, coverImageId, coverAlt, coverCaption });
|
||||
if (!initialized) {
|
||||
@@ -44,29 +80,13 @@
|
||||
}
|
||||
if (content === lastContent) return;
|
||||
lastContent = content;
|
||||
const sequence = ++saveSequence;
|
||||
saveQueued = true;
|
||||
saveState = 'saving';
|
||||
const timer = window.setTimeout(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/articles/draft', {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload())
|
||||
});
|
||||
const result = await response.json();
|
||||
if (sequence !== saveSequence) return;
|
||||
if (response.status === 409) {
|
||||
saveState = 'conflict';
|
||||
return;
|
||||
}
|
||||
if (!response.ok) throw new Error(result.message || 'Draft save failed.');
|
||||
version = result.version;
|
||||
saveState = 'saved';
|
||||
} catch (error) {
|
||||
if (sequence !== saveSequence) return;
|
||||
saveState = 'error';
|
||||
toast.error('Draft not saved', error instanceof Error ? error.message : 'Try again.');
|
||||
}
|
||||
}, 900);
|
||||
return () => window.clearTimeout(timer);
|
||||
if (!saveInFlight) scheduleSave();
|
||||
return () => {
|
||||
if (saveTimer !== undefined) window.clearTimeout(saveTimer);
|
||||
saveTimer = undefined;
|
||||
};
|
||||
});
|
||||
|
||||
function toggleTag(id: string) {
|
||||
@@ -115,7 +135,7 @@
|
||||
<div class="article-publication-note"><p>{visibility === 'private' ? 'Only you can open this draft.' : visibility === 'unlisted' ? 'Anyone with the direct URL can read it.' : 'Listed in discovery and visible to everyone.'}</p>{#if data.article.pendingRevisionId}<strong>MODERATION REVIEW PENDING</strong>{/if}</div>
|
||||
<PendingForm action="?/publish" class="article-publish-form">
|
||||
<input type="hidden" name="articleId" value={data.article.id}/><input type="hidden" name="title" value={title}/><input type="hidden" name="deck" value={deck}/><input type="hidden" name="document" value={document}/><input type="hidden" name="tagIds" value={JSON.stringify(selectedTags)}/><input type="hidden" name="channel" value={channel}/><input type="hidden" name="coverImageId" value={coverImageId}/><input type="hidden" name="coverAlt" value={coverAlt}/><input type="hidden" name="coverCaption" value={coverCaption}/><input type="hidden" name="version" value={version}/><input type="hidden" name="visibility" value={visibility}/>
|
||||
<button class="primary">{visibility === 'private' ? 'SAVE AS PRIVATE' : data.article.kind === 'guide' ? 'SUBMIT FOR PUBLICATION' : 'PUBLISH ARTICLE'}</button>
|
||||
<button class="primary" disabled={saveState === 'saving' || coverUploading}>{visibility === 'private' ? 'SAVE AS PRIVATE' : data.article.kind === 'guide' ? 'SUBMIT FOR PUBLICATION' : 'PUBLISH ARTICLE'}</button>
|
||||
</PendingForm>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -24,6 +24,16 @@
|
||||
function link(mark: ArticleMark) {
|
||||
return mark.type === 'link' ? mark.attrs?.href ?? '#' : '#';
|
||||
}
|
||||
|
||||
function orderedListStart(node: ArticleNode) {
|
||||
const start = Number(node.attrs?.start ?? 1);
|
||||
return Number.isInteger(start) && start > 1 ? start : undefined;
|
||||
}
|
||||
|
||||
function orderedListType(node: ArticleNode): 'a' | 'A' | 'i' | 'I' | undefined {
|
||||
const type = node.attrs?.type;
|
||||
return ['a', 'A', 'i', 'I'].includes(String(type)) ? type as 'a' | 'A' | 'i' | 'I' : undefined;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet renderText(text: string, marks: ArticleMark[], index = 0)}
|
||||
@@ -32,7 +42,7 @@
|
||||
{#if mark.type === 'bold'}<strong>{@render renderText(text, marks, index + 1)}</strong>
|
||||
{:else if mark.type === 'italic'}<em>{@render renderText(text, marks, index + 1)}</em>
|
||||
{:else if mark.type === 'code'}<code>{@render renderText(text, marks, index + 1)}</code>
|
||||
{:else}<a href={link(mark)} rel="nofollow ugc noopener noreferrer">{@render renderText(text, marks, index + 1)}</a>{/if}
|
||||
{:else}<a href={link(mark)} title={mark.attrs?.title} rel="nofollow ugc noopener noreferrer">{@render renderText(text, marks, index + 1)}</a>{/if}
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
@@ -46,7 +56,7 @@
|
||||
{:else if node.type === 'heading' && Number(node.attrs?.level) === 2}<h2>{@render children(node)}</h2>
|
||||
{:else if node.type === 'heading'}<h3>{@render children(node)}</h3>
|
||||
{:else if node.type === 'bulletList'}<ul>{@render children(node)}</ul>
|
||||
{:else if node.type === 'orderedList'}<ol>{@render children(node)}</ol>
|
||||
{:else if node.type === 'orderedList'}<ol start={orderedListStart(node)} type={orderedListType(node)}>{@render children(node)}</ol>
|
||||
{:else if node.type === 'listItem'}<li>{@render children(node)}</li>
|
||||
{:else if node.type === 'blockquote'}<blockquote>{@render children(node)}</blockquote>
|
||||
{:else if node.type === 'horizontalRule'}<hr/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import AgentIdentityLink from '$lib/components/AgentIdentityLink.svelte';
|
||||
import ArticleContent from '$lib/components/ArticleContent.svelte';
|
||||
import ArticleDiscussion from '$lib/components/ArticleDiscussion.svelte';
|
||||
import AgentIdentityLink from '$lib/components/AgentIdentityLink.svelte';
|
||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||
import { untrack } from 'svelte';
|
||||
let { data, form = null } = $props();
|
||||
@@ -25,7 +25,7 @@
|
||||
<header class="article-reader-head">
|
||||
<div class="article-reader-channel"><span>{data.article.channel ?? (data.article.kind === 'guide' ? 'COMMUNITY GUIDE' : 'NEWS')}</span>{#if data.article.featured}<b>FEATURED</b>{/if}{#if data.article.recommended}<b>RECOMMENDED</b>{/if}</div>
|
||||
<h1>{data.article.title}</h1><p>{data.article.deck}</p>
|
||||
<div class="article-reader-byline">{#if data.article.source}<div class="article-source-identity"><strong>{data.article.source.name}</strong><span>{publicationMeta}</span></div>{:else}<AgentIdentityLink size="compact" linked={Boolean(data.article.authorPublicSlug)} agent={{id:data.article.ownerId,displayName:data.article.author,publicSlug:data.article.authorPublicSlug,avatarUrl:`/avatars/user/${data.article.ownerId}`}} meta={publicationMeta}/>{/if}<div>{#if data.article.kind === 'guide' && data.article.owner}<a class="secondary" href={`/guides/${encodeURIComponent(data.article.slug)}/edit`}>EDIT GUIDE</a>{:else if data.article.kind === 'news' && data.canManageNews && !data.article.source}<a class="secondary" href={`/news/${encodeURIComponent(data.article.slug)}/edit`}>OPEN NEWSROOM</a>{/if}</div></div>
|
||||
<div class="article-reader-byline">{#if data.article.source}<div class="article-source-identity"><strong>{data.article.source.name}</strong><span>{publicationMeta}</span></div>{:else}<AgentIdentityLink size="compact" linked={Boolean(data.article.authorPublicSlug)} agent={{id:data.article.ownerId,displayName:data.article.author,publicSlug:data.article.authorPublicSlug,avatarUrl:`/avatars/user/${data.article.ownerId}`}} meta={publicationMeta}/>{/if}<div>{#if data.article.kind === 'guide' && data.article.owner}<a class="secondary" href={`/guides/${encodeURIComponent(data.article.slug)}/edit`}>EDIT GUIDE</a>{:else if data.article.kind === 'news' && data.canManageNews && !data.article.source}<a class="secondary" href={`/news/${encodeURIComponent(data.article.slug)}/edit`}>Edit Article</a>{/if}</div></div>
|
||||
{#if data.article.source}<aside class="article-source-notice"><div><span>OFFICIAL SOURCE</span><strong>Originally published by {data.article.source.name}</strong></div><a href={data.article.source.url} target="_blank" rel="noopener noreferrer">READ ON UBISOFT</a></aside>{/if}
|
||||
{#if data.tags.length}<nav class="article-reader-tags" aria-label="Article tags">{#each data.tags as tag}<a href={`/${path}?tag=${encodeURIComponent(tag.slug)}`}>{tag.name}</a>{/each}</nav>{/if}
|
||||
</header>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { buildShareUrl, copyBuildLink, shareBuild } from '$lib/build-sharing';
|
||||
import { toast } from '$lib/toast';
|
||||
|
||||
let {
|
||||
title,
|
||||
slugOrUrl,
|
||||
visibility
|
||||
}: {
|
||||
title: string;
|
||||
slugOrUrl?: string | null;
|
||||
visibility: 'public' | 'unlisted' | 'private';
|
||||
} = $props();
|
||||
|
||||
let pending = $state<'share' | 'copy' | null>(null);
|
||||
let status = $state('');
|
||||
let nativeShareAvailable = $state(false);
|
||||
const canShare = $derived((visibility === 'public' || visibility === 'unlisted') && Boolean(slugOrUrl?.trim()));
|
||||
|
||||
onMount(() => {
|
||||
nativeShareAvailable = typeof navigator.share === 'function';
|
||||
});
|
||||
|
||||
function shareUrl() {
|
||||
return buildShareUrl(slugOrUrl ?? '', window.location.origin);
|
||||
}
|
||||
|
||||
async function handleShare() {
|
||||
if (pending) return;
|
||||
pending = 'share';
|
||||
const result = await shareBuild(navigator, { title, url: shareUrl() });
|
||||
pending = null;
|
||||
|
||||
if (result.state === 'shared') {
|
||||
status = 'Build shared.';
|
||||
toast.success('BUILD SHARED', 'The system share sheet accepted the Build link.');
|
||||
} else if (result.state === 'cancelled') {
|
||||
status = 'Sharing cancelled.';
|
||||
toast.info('SHARING CANCELLED', 'No link was shared.');
|
||||
} else if (result.state === 'unsupported') {
|
||||
status = 'Native sharing is not supported on this device. Use Copy Link instead.';
|
||||
toast.info('SHARE NOT SUPPORTED', 'Use COPY LINK to copy the Build URL.');
|
||||
} else {
|
||||
status = 'The Build could not be shared. Try Copy Link instead.';
|
||||
toast.error('BUILD NOT SHARED', 'Try COPY LINK or try again.');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopy() {
|
||||
if (pending) return;
|
||||
pending = 'copy';
|
||||
const result = await copyBuildLink(navigator, shareUrl());
|
||||
pending = null;
|
||||
|
||||
if (result.state === 'copied') {
|
||||
status = 'Build link copied to the clipboard.';
|
||||
toast.success('LINK COPIED', 'Paste the Build link wherever you want to share it.');
|
||||
} else if (result.state === 'unsupported') {
|
||||
status = 'Clipboard copying is not supported in this browser.';
|
||||
toast.info('COPY NOT SUPPORTED', 'This browser cannot copy the Build link automatically.');
|
||||
} else {
|
||||
status = 'The Build link could not be copied.';
|
||||
toast.error('LINK NOT COPIED', 'Check clipboard permissions and try again.');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="build-share-control" aria-label="Share Build">
|
||||
{#if canShare}
|
||||
<div class="build-share-heading">
|
||||
<div>
|
||||
<span>BUILD UPLINK</span>
|
||||
<strong>{visibility === 'unlisted' ? 'UNLISTED LINK READY' : 'PUBLIC LINK READY'}</strong>
|
||||
</div>
|
||||
<span class="build-share-signal" aria-hidden="true"></span>
|
||||
</div>
|
||||
<div class:single={!nativeShareAvailable} class="build-share-actions">
|
||||
{#if nativeShareAvailable}
|
||||
<button class="build-share-primary" type="button" onclick={handleShare} disabled={pending !== null} aria-busy={pending === 'share'}>
|
||||
{pending === 'share' ? 'OPENING SHARE…' : 'SHARE BUILD →'}
|
||||
</button>
|
||||
{/if}
|
||||
<button class:build-share-primary={!nativeShareAvailable} class:build-share-copy={nativeShareAvailable} type="button" onclick={handleCopy} disabled={pending !== null} aria-busy={pending === 'copy'}>
|
||||
{pending === 'copy' ? 'COPYING…' : 'COPY LINK'}
|
||||
</button>
|
||||
</div>
|
||||
<p class="build-share-discord"><img src="/discord-symbol.svg" alt="" aria-hidden="true" />Discord will render the Build preview when you post the copied link.</p>
|
||||
{:else}
|
||||
<div class="build-share-locked">
|
||||
<span>{slugOrUrl?.trim() ? 'PRIVATE BUILD' : 'UNSAVED BUILD'}</span>
|
||||
<strong>PUBLIC LINK UNAVAILABLE</strong>
|
||||
<p>{slugOrUrl?.trim() ? 'Change visibility to Public or Unlisted before sharing this Build.' : 'Save this Build before creating a public or unlisted sharing link.'}</p>
|
||||
</div>
|
||||
{/if}
|
||||
<p class="build-share-status" aria-live="polite" aria-atomic="true">{status}</p>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.build-share-control{display:grid;border:1px solid var(--shd-border-strong);background:#0d1011;font-family:var(--shd-font-sans)}
|
||||
.build-share-heading{min-height:48px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:9px 11px;border-bottom:1px solid var(--shd-border);background:var(--shd-surface-raised)}
|
||||
.build-share-heading>div,.build-share-locked{display:grid;gap:3px}.build-share-heading span,.build-share-locked>span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.9px}.build-share-heading strong,.build-share-locked strong{font-size:var(--shd-type-supporting);letter-spacing:.45px}.build-share-signal{width:8px;height:8px;border:1px solid var(--shd-orange);background:var(--shd-orange);box-shadow:0 0 0 3px #f6a8211c}
|
||||
.build-share-actions{display:grid;grid-template-columns:minmax(0,1fr) minmax(120px,.45fr);gap:8px;padding:10px}.build-share-actions button{min-height:44px;padding:0 13px;border-radius:0;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px;cursor:pointer;transition:var(--shd-transition)}.build-share-primary{border:1px solid var(--shd-orange);background:var(--shd-orange);color:#111415}.build-share-copy{border:1px solid var(--shd-border-strong);background:var(--shd-surface);color:var(--shd-text)}.build-share-primary:hover{background:var(--shd-orange-hover);border-color:var(--shd-orange-hover)}.build-share-copy:hover{border-color:var(--shd-text-muted);background:var(--shd-surface-active)}.build-share-actions button:focus-visible{outline:2px solid var(--shd-focus-ring);outline-offset:2px}.build-share-actions button:active:not(:disabled){transform:translateY(1px);filter:brightness(.86)}.build-share-actions button:disabled{cursor:not-allowed;opacity:.48}
|
||||
.build-share-actions.single{grid-template-columns:1fr}
|
||||
.build-share-discord{display:flex;align-items:center;gap:7px;margin:0;padding:0 11px 10px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.35}.build-share-discord img{width:16px;height:13px;object-fit:contain;opacity:.72}.build-share-locked{padding:14px}.build-share-locked>span{color:var(--shd-danger)}.build-share-locked p{margin:2px 0 0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}.build-share-status{min-height:0;margin:0;padding:0 11px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry)}.build-share-status:not(:empty){min-height:32px;padding-top:7px;padding-bottom:7px;border-top:1px solid var(--shd-border-soft)}
|
||||
@media(max-width:560px){.build-share-actions{grid-template-columns:1fr}.build-share-actions button{width:100%;min-height:44px}}
|
||||
@media(prefers-reduced-motion:reduce){.build-share-actions button{transition:none}.build-share-actions button:active:not(:disabled){transform:none}}
|
||||
</style>
|
||||
@@ -5,7 +5,7 @@
|
||||
items,
|
||||
value = $bindable(''),
|
||||
label,
|
||||
placeholder = 'Search available items…',
|
||||
placeholder = 'Search…',
|
||||
disabledIds = [],
|
||||
onchange
|
||||
}: {
|
||||
@@ -27,19 +27,19 @@
|
||||
<span>{label}</span>
|
||||
<button type="button" class="builder-picker-current" aria-expanded={open} onclick={() => open = !open}>
|
||||
<ItemIcon src={selected?.iconUrl} alt=""/>
|
||||
<b>{selected?.name ?? 'SELECT ITEM'}</b><small>{selected?.category ?? (selected ? 'EQUIPPED' : 'UNASSIGNED')}</small><em>{open ? '×' : '+'}</em>
|
||||
<b>{selected?.name ?? 'SELECT ITEM'}</b><small>{selected?.category ?? (selected ? 'EQUIPPED' : 'UNASSIGNED')}</small><em>{open ? '×' : '+'}</em>
|
||||
</button>
|
||||
{#if open}
|
||||
<div class="builder-picker-panel">
|
||||
<input type="search" bind:value={query} {placeholder} aria-label={placeholder}/>
|
||||
<div>
|
||||
{#if selected}<button type="button" class="builder-picker-clear" onclick={() => choose('')}><i>×</i><span><b>CLEAR SLOT</b><small>REMOVE CURRENT SELECTION</small></span><em>CLEAR</em></button>{/if}
|
||||
{#if selected}<button type="button" class="builder-picker-clear" onclick={() => choose('')}><i>×</i><span><b>CLEAR</b><small>REMOVE SELECTION</small></span><em>CLEAR</em></button>{/if}
|
||||
{#each results as item}
|
||||
<button type="button" class:selected={item.id === value} disabled={disabledIds.includes(item.id)} onclick={() => choose(item.id)}>
|
||||
<ItemIcon src={item.iconUrl} alt=""/>
|
||||
<span><b>{item.name}</b><small>{item.category ?? 'AVAILABLE'}</small></span><em>{item.id === value ? 'EQUIPPED' : disabledIds.includes(item.id) ? 'CONFLICT' : 'SELECT'}</em>
|
||||
</button>
|
||||
{:else}<p>NO MATCHING ITEMS</p>{/each}
|
||||
{:else}<p>NO MATCHES</p>{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
} = $props();
|
||||
|
||||
let query = $state('');
|
||||
let open = $state(false);
|
||||
const selected = $derived(items.find((item) => item.id === value));
|
||||
const visible = $derived(items.filter((item) =>
|
||||
!query.trim() || `${item.name} ${item.category ?? ''}`.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase())
|
||||
));
|
||||
@@ -31,26 +33,33 @@
|
||||
function choose(id: string) {
|
||||
value = id;
|
||||
onchange?.(id);
|
||||
if (searchable) {
|
||||
open = false;
|
||||
query = '';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<fieldset class="builder-radio-grid" class:icon-only={iconOnly} style={`--builder-radio-columns:${Math.max(1, columns)}`}>
|
||||
<legend>{label}</legend>
|
||||
{#if searchable}
|
||||
{#if searchable}<button type="button" class="builder-radio-toggle" aria-expanded={open} onclick={() => open = !open}><ItemIcon src={selected?.iconUrl} alt=""/><span><b>{selected?.name ?? 'SELECT OPTION'}</b><small>{selected?.category ?? 'NOT CONFIGURED'}</small></span><em>{open ? 'CLOSE' : 'CHANGE'}</em></button>{/if}
|
||||
{#if searchable && open}
|
||||
<input class="builder-radio-search" type="search" bind:value={query} placeholder="SEARCH AVAILABLE OPTIONS" aria-label={`Search ${label.toLocaleLowerCase()}`}/>
|
||||
{/if}
|
||||
<div>
|
||||
{#each visible as item}
|
||||
<label title={item.name}>
|
||||
<input type="radio" {name} value={item.id} checked={value === item.id} {required} aria-label={item.name} onchange={() => choose(item.id)}/>
|
||||
<span>
|
||||
<ItemIcon src={item.iconUrl} alt=""/>
|
||||
<b>{item.name}</b>
|
||||
{#if item.category}<small>{item.category}</small>{/if}
|
||||
</span>
|
||||
</label>
|
||||
{:else}
|
||||
<p>NO MATCHING OPTIONS</p>
|
||||
{/each}
|
||||
</div>
|
||||
{#if !searchable || open}
|
||||
<div>
|
||||
{#each visible as item}
|
||||
<label title={item.name}>
|
||||
<input type="radio" {name} value={item.id} checked={value === item.id} {required} aria-label={item.name} onchange={() => choose(item.id)}/>
|
||||
<span>
|
||||
<ItemIcon src={item.iconUrl} alt=""/>
|
||||
<b>{item.name}</b>
|
||||
{#if item.category}<small>{item.category}</small>{/if}
|
||||
</span>
|
||||
</label>
|
||||
{:else}
|
||||
<p>NO MATCHING OPTIONS</p>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</fieldset>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import type { GameIconName } from '$lib/game-icons';
|
||||
import type { DatabaseItem } from '$lib/database/items';
|
||||
import MarkdownContent from './MarkdownContent.svelte';
|
||||
import WeaponModSlotIcon from './WeaponModSlotIcon.svelte';
|
||||
|
||||
let {
|
||||
item,
|
||||
@@ -16,9 +17,21 @@
|
||||
hideDiscussionUtility?: boolean;
|
||||
anchor?: boolean;
|
||||
} = $props();
|
||||
let protocolLevel = $state(1);
|
||||
let talentUpgrades = $state(0);
|
||||
|
||||
const number = (value: number) => Number.isInteger(value) ? String(value) : value.toFixed(2).replace(/\.?0+$/, '');
|
||||
const valueWithUnit = (value: number, unit: string) => `${number(value)}${unit ? (unit === '%' ? '%' : ` ${unit}`) : ''}`;
|
||||
const protocolPrimaryValue = () => item.protocolPreview
|
||||
? item.protocolPreview.primaryBaseValue * (1 + (protocolLevel - 1) / 49)
|
||||
: 0;
|
||||
const talentDescription = () => item.protocolPreview?.talentDescription?.replace(/\{(\d+)\}/g, (placeholder, raw) => {
|
||||
const effect = item.protocolPreview?.talentEffects[Number(raw) - 1];
|
||||
return effect ? valueWithUnit(effect.baseValue * (1 + talentUpgrades / 5), effect.unit) : placeholder;
|
||||
});
|
||||
</script>
|
||||
|
||||
<article id={anchor ? `database-item-${item.id}` : undefined} class="database-item-card" class:talent-card={item.variant === 'talent'}>
|
||||
<article id={anchor ? `database-item-${item.id}` : undefined} class="database-item-card" class:talent-card={item.variant === 'talent'} class:protocol-quality-standard={item.topBorderTone === 'standard'} class:protocol-quality-specialized={item.topBorderTone === 'specialized'} class:protocol-quality-superior={item.topBorderTone === 'superior'} class:protocol-quality-highend={item.topBorderTone === 'highend'}>
|
||||
<header>
|
||||
{#if item.renderUrl}<img class="database-item-render" src={item.renderUrl} alt="" loading="lazy"/>{/if}
|
||||
<div class="database-item-icon">
|
||||
@@ -31,13 +44,38 @@
|
||||
{#if item.headerBadges?.length || item.headerBadge || item.headerMetaIcon}
|
||||
<div class="database-header-status">
|
||||
{#if item.headerMetaIcon}<GameIcon name={item.headerMetaIcon}/>{/if}
|
||||
{#each item.headerBadges ?? [] as badge}<span class="database-header-badge">{badge}</span>{/each}
|
||||
{#if item.headerBadge}<span class="database-header-badge">{item.headerBadge}</span>{/if}
|
||||
{#each item.headerBadges ?? [] as badge}<span class="database-header-badge" class:exotic={badge === 'EXOTIC'}>{badge}</span>{/each}
|
||||
{#if item.headerBadge}<span class="database-header-badge" class:protocol-badge-standard={item.headerBadgeTone === 'standard'} class:protocol-badge-specialized={item.headerBadgeTone === 'specialized'} class:protocol-badge-superior={item.headerBadgeTone === 'superior'} class:protocol-badge-highend={item.headerBadgeTone === 'highend'}>{item.headerBadge}</span>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
{#if item.badges?.length}<div class="database-item-badges">{#each item.badges as badge}<span class={badge.tone ?? ''}>{badge.label}</span>{/each}</div>{/if}
|
||||
<DatabaseFacts facts={item.facts} layout={item.factLayout}/>
|
||||
{#if item.protocolPreview}
|
||||
<section class="database-protocol-preview">
|
||||
<div class="database-protocol-primary"><span>{item.protocolPreview.primaryStat}</span><b>{Number.isFinite(protocolPrimaryValue()) ? valueWithUnit(protocolPrimaryValue(), item.protocolPreview.primaryUnit) : 'NOT CONFIGURED'}</b></div>
|
||||
{#if item.protocolPreview.talentDescription}
|
||||
<div class="database-protocol-talent"><p>{talentDescription()}</p></div>
|
||||
{/if}
|
||||
<div class="database-protocol-controls"><label class="database-protocol-level"><span>LVL {protocolLevel}</span><input type="range" min="1" max="50" step="1" bind:value={protocolLevel} aria-label={`${item.name} level`}/></label>{#if item.protocolPreview.talentDescription}<div class="database-protocol-diamonds" role="group" aria-label={`${item.name} talent upgrades`}>{#each [1, 2, 3, 4, 5] as upgrade}<button type="button" class:active={talentUpgrades >= upgrade} aria-label={`Set talent upgrades to ${upgrade}`} aria-pressed={talentUpgrades === upgrade} onclick={() => talentUpgrades = talentUpgrades === upgrade ? 0 : upgrade}>◆</button>{/each}</div>{/if}</div>
|
||||
</section>
|
||||
{:else}
|
||||
<DatabaseFacts facts={item.facts} layout={item.factLayout}/>
|
||||
{/if}
|
||||
{#if item.weaponModSlots}
|
||||
<div class="database-weapon-mod-slots">
|
||||
<div><span>MOD SLOTS</span></div>
|
||||
{#if item.weaponModSlots.length}
|
||||
<ul aria-label={`${item.name} Weapon Mod slots`}>
|
||||
{#each item.weaponModSlots as modSlot}
|
||||
<li class:upgrade-slot={modSlot.unlockedAtTierOne} class:has-slot-icon={Boolean(modSlot.iconUrl)} title={`${modSlot.slot}${modSlot.unlockedAtTierOne ? ' - Unlocked when this weapon upgrades to Tier 1' : ''}`}>
|
||||
{#if modSlot.iconUrl}<WeaponModSlotIcon slot={modSlot.slot} iconUrl={modSlot.iconUrl}/><span class="sr-only">{modSlot.slot}</span>{:else}<span>{modSlot.slot}</span>{/if}
|
||||
{#if modSlot.unlockedAtTierOne}<small>T1+</small>{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}<p>MOD SLOT DATA NOT CONFIGURED</p>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if item.lines?.length}
|
||||
<div class="database-item-lines">{#each item.lines as line}<div><span>{line.label}</span><b>{line.value}</b></div>{/each}</div>
|
||||
{/if}
|
||||
@@ -53,7 +91,7 @@
|
||||
<section><span>TIER I</span><p>{item.description ?? 'Description is not available.'}</p></section>
|
||||
<section><span>TIER II</span><p>{item.secondaryDescription ?? 'Description is not available.'}</p></section>
|
||||
</div>
|
||||
{:else}
|
||||
{:else if !item.protocolPreview}
|
||||
{#if item.description}<p class:data-description={item.descriptionStyle === 'data'}>{item.description}</p>{/if}
|
||||
{#if item.secondaryDescription}<p class="secondary-description" class:data-description={item.descriptionStyle === 'data'}>{item.secondaryDescription}</p>{/if}
|
||||
{/if}
|
||||
|
||||
@@ -75,7 +75,11 @@
|
||||
}
|
||||
|
||||
function paste(event: ClipboardEvent) {
|
||||
const file = Array.from(event.clipboardData?.files ?? []).find((item) => item.type.startsWith('image/'));
|
||||
const file =
|
||||
Array.from(event.clipboardData?.items ?? [])
|
||||
.find((item) => item.kind === 'file' && item.type.startsWith('image/'))
|
||||
?.getAsFile() ??
|
||||
Array.from(event.clipboardData?.files ?? []).find((item) => item.type.startsWith('image/'));
|
||||
if (file) {
|
||||
event.preventDefault();
|
||||
setFile(file);
|
||||
@@ -107,10 +111,12 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -- focus is required as a scoped native paste target -->
|
||||
<div
|
||||
class="image-drop-input"
|
||||
class:dragging
|
||||
role="group"
|
||||
tabindex="0"
|
||||
aria-label="Drop an image here or paste one from the clipboard"
|
||||
ondragenter={(event) => { event.preventDefault(); dragging = true; }}
|
||||
ondragover={(event) => event.preventDefault()}
|
||||
|
||||
@@ -14,4 +14,4 @@
|
||||
const resolvedAlt = $derived(src || !alt ? alt : `${alt} — icon awaiting contribution`);
|
||||
</script>
|
||||
|
||||
<img src={src || MISSING_ITEM_ICON_URL} alt={resolvedAlt} class:missing-item-icon={!src} class={className}/>
|
||||
<img src={src || MISSING_ITEM_ICON_URL} alt={resolvedAlt} class:missing-item-icon={!src} class={`item-icon ${className}`.trim()}/>
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
<script lang="ts">
|
||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||||
import {
|
||||
MOD_QUALITIES,
|
||||
MOD_SECONDARY_CAPACITY,
|
||||
MOD_SECONDARY_QUALITIES,
|
||||
type ModSecondaryRange
|
||||
} from '$lib/mods/secondary-pool';
|
||||
|
||||
let {
|
||||
kind,
|
||||
pool,
|
||||
stats,
|
||||
error,
|
||||
saved = false,
|
||||
resultToken = ''
|
||||
}: {
|
||||
kind: 'Weapon Mod' | 'Gear Mod';
|
||||
pool: ModSecondaryRange[];
|
||||
stats: Array<{ id: string; name: string; unit: string }>;
|
||||
error?: string;
|
||||
saved?: boolean;
|
||||
resultToken?: string;
|
||||
} = $props();
|
||||
|
||||
const poolId = $derived(kind === 'Weapon Mod' ? 'weapon-mod-secondary-pool' : 'gear-mod-secondary-pool');
|
||||
const action = '?/saveSecondaryPool';
|
||||
const qualityClass = (quality: string) => `quality-${quality.toLowerCase().replace('-', '')}`;
|
||||
const blankSecondary = (): ModSecondaryRange => ({
|
||||
statId: '',
|
||||
values: Object.fromEntries(
|
||||
MOD_SECONDARY_QUALITIES.map((quality) => [quality, {
|
||||
level1Min: 0,
|
||||
level1Max: 0,
|
||||
level20Min: 0,
|
||||
level20Max: 0
|
||||
}])
|
||||
) as ModSecondaryRange['values']
|
||||
});
|
||||
let editing = $state(false);
|
||||
let draft = $state<ModSecondaryRange[]>([]);
|
||||
let handledResultToken = $state('');
|
||||
|
||||
function openEditor() {
|
||||
draft = pool.length ? structuredClone(pool) : [blankSecondary(), blankSecondary()];
|
||||
editing = true;
|
||||
}
|
||||
|
||||
function addSecondary() {
|
||||
if (draft.length < 50) draft = [...draft, blankSecondary()];
|
||||
}
|
||||
|
||||
function removeSecondary(index: number) {
|
||||
if (draft.length > 2) draft = draft.filter((_, itemIndex) => itemIndex !== index);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (saved && resultToken && resultToken !== handledResultToken) {
|
||||
handledResultToken = resultToken;
|
||||
editing = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="mod-secondary-pool-registry">
|
||||
<div class="selection-head">
|
||||
<div>
|
||||
<span>SHARED {kind.toUpperCase()} SECONDARY POOL</span>
|
||||
<small>ONE CANONICAL POOL FOR EVERY REGULAR {kind.toUpperCase()}</small>
|
||||
</div>
|
||||
<button type="button" class="row-action" onclick={openEditor}>EDIT SHARED POOL →</button>
|
||||
</div>
|
||||
<div class="mod-secondary-capacity">
|
||||
{#each MOD_QUALITIES as quality}
|
||||
<span><b class={qualityClass(quality)}>{quality.toUpperCase()}</b><strong>{MOD_SECONDARY_CAPACITY[quality]}</strong><small>SECONDARY {MOD_SECONDARY_CAPACITY[quality] === 1 ? 'ROLL' : 'ROLLS'}</small></span>
|
||||
{/each}
|
||||
<span class="pool-total"><b>ATTRIBUTE POOL</b><strong>{pool.length}</strong><small>SHARED OPTIONS</small></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if editing}
|
||||
<div class="dashboard-editor mod-secondary-pool-editor">
|
||||
<div class="editor-title">
|
||||
<div><p class="eyebrow">SHARED CONFIGURATION</p><h3>{kind.toUpperCase()} SECONDARY ATTRIBUTES</h3></div>
|
||||
<button aria-label={`Close ${kind} secondary pool editor`} onclick={() => editing = false}>×</button>
|
||||
</div>
|
||||
<PendingForm {action}>
|
||||
<input type="hidden" name="id" value={poolId}/>
|
||||
<input type="hidden" name="name" value={`Shared ${kind} Secondary Pool`}/>
|
||||
<input type="hidden" name="secondaryRanges" value={JSON.stringify(draft)}/>
|
||||
<p class="console-note">Standard quality has no secondary roll. Specialized selects one option; Superior and High-end select two distinct options. Ranges scale linearly from Level 1 to Level 20.</p>
|
||||
<div class="selection-head pool-editor-head">
|
||||
<div><span>AVAILABLE ATTRIBUTES</span><small>SPECIALIZED · SUPERIOR · HIGH-END LEVEL ENDPOINTS</small></div>
|
||||
<button type="button" class="row-action" onclick={addSecondary} disabled={draft.length >= 50}>ADD ATTRIBUTE +</button>
|
||||
</div>
|
||||
<div class="mod-secondary-pool-options">
|
||||
{#each draft as range, index}
|
||||
<div class="mod-secondary-pool-option">
|
||||
<strong>{String(index + 1).padStart(2, '0')}</strong>
|
||||
<SearchableAssignment
|
||||
items={stats}
|
||||
mode="single"
|
||||
compact
|
||||
subjectLabel="POSSIBLE ATTRIBUTE"
|
||||
searchPlaceholder="Search attributes…"
|
||||
excludedIds={draft.filter((_, itemIndex) => itemIndex !== index).map((item) => item.statId)}
|
||||
bind:value={range.statId}
|
||||
/>
|
||||
{#each MOD_SECONDARY_QUALITIES as quality}
|
||||
<div class="mod-secondary-quality-range">
|
||||
<b class={qualityClass(quality)}>{quality.toUpperCase()}</b>
|
||||
<label><span>LEVEL 1</span><div><input type="number" step="any" bind:value={range.values[quality].level1Min} aria-label={`${quality} ${stats.find((item) => item.id === range.statId)?.name ?? 'attribute'} Level 1 minimum`} required/><i>–</i><input type="number" step="any" bind:value={range.values[quality].level1Max} aria-label={`${quality} ${stats.find((item) => item.id === range.statId)?.name ?? 'attribute'} Level 1 maximum`} required/></div></label>
|
||||
<label><span>LEVEL 20</span><div><input type="number" step="any" bind:value={range.values[quality].level20Min} aria-label={`${quality} ${stats.find((item) => item.id === range.statId)?.name ?? 'attribute'} Level 20 minimum`} required/><i>–</i><input type="number" step="any" bind:value={range.values[quality].level20Max} aria-label={`${quality} ${stats.find((item) => item.id === range.statId)?.name ?? 'attribute'} Level 20 maximum`} required/></div></label>
|
||||
</div>
|
||||
{/each}
|
||||
<button type="button" aria-label={`Remove shared secondary attribute option ${index + 1}`} onclick={() => removeSecondary(index)} disabled={draft.length <= 2}>×</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if error}<p class="error">{error}</p>{/if}
|
||||
<div class="form-actions"><button type="button" class="secondary" onclick={() => editing = false}>CANCEL</button><button class="primary">SAVE SHARED POOL →</button></div>
|
||||
</PendingForm>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -29,8 +29,6 @@
|
||||
gear_max: 'gearMax',
|
||||
weapon_min: 'weaponMin',
|
||||
weapon_max: 'weaponMax',
|
||||
skill_chip_min: 'chipMin',
|
||||
skill_chip_max: 'chipMax',
|
||||
cap_value: 'capValue'
|
||||
};
|
||||
return proposed[names[key] ?? key];
|
||||
@@ -46,6 +44,8 @@
|
||||
);
|
||||
const specializationRecord = $derived((currentSnapshot?.record ?? null) as Record<string, unknown> | null);
|
||||
const skillChipRecord = $derived((currentSnapshot?.record ?? null) as Record<string, unknown> | null);
|
||||
const currentSharedSkillChipProfiles = $derived((Array.isArray(currentSnapshot) ? currentSnapshot : []) as any[]);
|
||||
const proposedSharedSkillChipProfiles = $derived((jsonValue(proposed.profiles) ?? []) as any[]);
|
||||
const skillChipFields = $derived([
|
||||
{ label: 'NAME', current: skillChipRecord?.name, proposed: proposed.name },
|
||||
{ label: 'SPECIALIZATION ABILITY', current: skillChipRecord?.specialization_ability_id, proposed: proposed.abilityId },
|
||||
@@ -78,6 +78,7 @@
|
||||
{ label: 'SPECIALIZATION', current: specializationRecord?.specialization_id, proposed: proposed.specializationId },
|
||||
{ label: 'DURATION · SECONDS', current: specializationRecord?.duration_seconds, proposed: proposed.durationSeconds },
|
||||
{ label: 'COOLDOWN · SECONDS', current: specializationRecord?.cooldown_seconds, proposed: proposed.cooldownSeconds },
|
||||
{ label: 'SHARED SKILL CHIP ABILITY', current: (currentSnapshot?.skillChipLinks as any[])?.[0]?.ability_id === specializationRecord?.id ? (currentSnapshot?.skillChipLinks as any[])?.[0]?.compatible_ability_id : (currentSnapshot?.skillChipLinks as any[])?.[0]?.ability_id, proposed: proposed.skillChipCompatibleAbilityId },
|
||||
{ label: 'DESCRIPTION', current: specializationRecord?.description, proposed: proposed.description }
|
||||
]
|
||||
: action === 'saveCombatEffect'
|
||||
@@ -256,7 +257,7 @@
|
||||
{#each [['type','TYPE'],['category','CATEGORY'],['calculation_type','CALCULATION'],['unit','UNIT']] as field}
|
||||
<div class="preview-field"><span>{field[1]}</span><strong>{shown(attributeValue('current', field[0]))}</strong></div>
|
||||
{/each}
|
||||
{#each [['gear','GEAR'],['weapon','WEAPON'],['skill_chip','SKILL CHIP']] as source}
|
||||
{#each [['gear','GEAR'],['weapon','WEAPON']] as source}
|
||||
<div class="preview-field range"><span>{source[1]} RANGE · OPTIONAL</span><div><strong>{shown(attributeValue('current', `${source[0]}_min`))}</strong><i>TO</i><strong>{shown(attributeValue('current', `${source[0]}_max`))}</strong></div></div>
|
||||
{/each}
|
||||
<div class="preview-field"><span>OPTIONAL CAP</span><strong>{shown(attributeValue('current', 'cap_value'))}</strong></div>
|
||||
@@ -273,13 +274,24 @@
|
||||
{#each [['type','TYPE'],['category','CATEGORY'],['calculation_type','CALCULATION'],['unit','UNIT']] as field}
|
||||
<div class="preview-field"><span>{field[1]}</span><strong>{shown(attributeValue('proposed', field[0]))}</strong></div>
|
||||
{/each}
|
||||
{#each [['gear','GEAR'],['weapon','WEAPON'],['skill_chip','SKILL CHIP']] as source}
|
||||
{#each [['gear','GEAR'],['weapon','WEAPON']] as source}
|
||||
<div class="preview-field range"><span>{source[1]} RANGE · OPTIONAL</span><div><strong>{shown(attributeValue('proposed', `${source[0]}_min`))}</strong><i>TO</i><strong>{shown(attributeValue('proposed', `${source[0]}_max`))}</strong></div></div>
|
||||
{/each}
|
||||
<div class="preview-field"><span>OPTIONAL CAP</span><strong>{shown(attributeValue('proposed', 'cap_value'))}</strong></div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
{:else if module === 'skillchips' && ['savePrimaryProfile','saveSecondaryPool'].includes(action)}
|
||||
<div class="proposal-compare-grid">
|
||||
<article class="proposal-editor-preview before">
|
||||
<header><span>CURRENT LIVE · SHARED {action === 'savePrimaryProfile' ? 'PRIMARY PROFILE' : 'SECONDARY POOL'}</span><b>BEFORE</b></header>
|
||||
<div class="proposal-structured-list">{#each currentSharedSkillChipProfiles as profile}<p>{profile.statId} · {Object.entries(profile.values ?? {}).map(([quality, range]: [string, any]) => `${quality.toUpperCase()} ${shown(range.min)}–${shown(range.max)}`).join(' · ')}</p>{:else}<p>SHARED PROFILE NOT CONFIGURED</p>{/each}</div>
|
||||
</article>
|
||||
<article class="proposal-editor-preview after">
|
||||
<header><span>PROPOSED · SHARED {action === 'savePrimaryProfile' ? 'PRIMARY PROFILE' : 'SECONDARY POOL'}</span><b>AFTER</b></header>
|
||||
<div class="proposal-structured-list">{#each proposedSharedSkillChipProfiles as profile}<p>{profile.statId} · {Object.entries(profile.values ?? {}).map(([quality, range]: [string, any]) => `${quality.toUpperCase()} ${shown(range.min)}–${shown(range.max)}`).join(' · ')}</p>{/each}</div>
|
||||
</article>
|
||||
</div>
|
||||
{:else if module === 'skillchips' && action === 'save'}
|
||||
<div class="proposal-compare-grid">
|
||||
<article class="proposal-editor-preview before">
|
||||
@@ -287,7 +299,7 @@
|
||||
{#if skillChipRecord}
|
||||
<div class="proposal-field-summary">
|
||||
{#each skillChipFields as field}<div><span>{field.label}</span><strong>{shown(field.current)}</strong></div>{/each}
|
||||
<div><span>ROLL POOLS</span><strong>{(currentSnapshot?.primaryOptions as any[])?.length ?? 0} PRIMARY · {(currentSnapshot?.secondaryOptions as any[])?.length ?? 0} SECONDARY</strong></div>
|
||||
<div><span>3-PIECE TALENT</span><strong>{currentSnapshot?.talent ? 'ASSIGNED' : 'NOT ASSIGNED'}</strong></div>
|
||||
</div>
|
||||
<div class="proposal-structured-list">
|
||||
<header><span>3-PIECE CONDITIONAL MECHANIC</span><b>TYPED RULE</b></header>
|
||||
@@ -301,7 +313,7 @@
|
||||
<header><span>PROPOSED · SKILL CHIP</span><b>AFTER</b></header>
|
||||
<div class="proposal-field-summary">
|
||||
{#each skillChipFields as field}<div><span>{field.label}</span><strong>{shown(field.proposed)}</strong></div>{/each}
|
||||
<div><span>ROLL POOLS</span><strong>{Array.isArray(proposed.primaryStatIds) ? proposed.primaryStatIds.length : Number(Boolean(proposed.primaryStatIds))} PRIMARY · {Array.isArray(proposed.secondaryStatIds) ? proposed.secondaryStatIds.length : Number(Boolean(proposed.secondaryStatIds))} SECONDARY</strong></div>
|
||||
<div><span>3-PIECE TALENT</span><strong>{proposed.hasTalent === '1' ? 'ASSIGNED' : 'NOT ASSIGNED'}</strong></div>
|
||||
</div>
|
||||
<div class="proposal-structured-list">
|
||||
<header><span>3-PIECE CONDITIONAL MECHANIC</span><b>TYPED RULE</b></header>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<script lang="ts">
|
||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||||
import {
|
||||
SKILL_CHIP_QUALITIES,
|
||||
SKILL_CHIP_SECONDARY_CAPACITY,
|
||||
SKILL_CHIP_SECONDARY_QUALITIES,
|
||||
type SkillChipProfile
|
||||
} from '$lib/skill-chips/profiles';
|
||||
|
||||
let {
|
||||
primaryProfiles,
|
||||
primaryProfileBase,
|
||||
secondaryPool,
|
||||
stats,
|
||||
primaryError,
|
||||
secondaryError,
|
||||
primarySaved = false,
|
||||
secondarySaved = false,
|
||||
resultToken = ''
|
||||
}: {
|
||||
primaryProfiles: SkillChipProfile[];
|
||||
primaryProfileBase: SkillChipProfile[];
|
||||
secondaryPool: SkillChipProfile[];
|
||||
stats: Array<{ id: string; name: string; unit: string }>;
|
||||
primaryError?: string;
|
||||
secondaryError?: string;
|
||||
primarySaved?: boolean;
|
||||
secondarySaved?: boolean;
|
||||
resultToken?: string;
|
||||
} = $props();
|
||||
|
||||
const qualityClass = (quality: string) => `quality-${quality.toLowerCase().replace('-', '')}`;
|
||||
const blankValues = (qualities: readonly string[]) => Object.fromEntries(
|
||||
qualities.map((quality) => [quality, { min: '', max: '' }])
|
||||
);
|
||||
const hydrated = (profile: SkillChipProfile, qualities: readonly string[]) => ({
|
||||
statId: profile.statId,
|
||||
values: Object.fromEntries(qualities.map((quality) => [quality, {
|
||||
min: profile.values[quality]?.min ?? '',
|
||||
max: profile.values[quality]?.max ?? ''
|
||||
}]))
|
||||
});
|
||||
const blankSecondary = () => ({ statId: '', values: blankValues(SKILL_CHIP_SECONDARY_QUALITIES) });
|
||||
let primaryEditing = $state(false);
|
||||
let secondaryEditing = $state(false);
|
||||
let primaryDraft = $state<any[]>([]);
|
||||
let secondaryDraft = $state<any[]>([]);
|
||||
let handledPrimaryToken = $state('');
|
||||
let handledSecondaryToken = $state('');
|
||||
|
||||
function openPrimary() {
|
||||
primaryDraft = primaryProfiles.map((profile) => hydrated(profile, SKILL_CHIP_QUALITIES));
|
||||
secondaryEditing = false;
|
||||
primaryEditing = true;
|
||||
}
|
||||
function openSecondary() {
|
||||
const loaded: any[] = secondaryPool.map((profile) =>
|
||||
hydrated(profile, SKILL_CHIP_SECONDARY_QUALITIES)
|
||||
);
|
||||
while (loaded.length < 2) loaded.push(blankSecondary());
|
||||
secondaryDraft = loaded;
|
||||
primaryEditing = false;
|
||||
secondaryEditing = true;
|
||||
}
|
||||
$effect(() => {
|
||||
if (primarySaved && resultToken && resultToken !== handledPrimaryToken) {
|
||||
handledPrimaryToken = resultToken;
|
||||
primaryEditing = false;
|
||||
}
|
||||
if (secondarySaved && resultToken && resultToken !== handledSecondaryToken) {
|
||||
handledSecondaryToken = resultToken;
|
||||
secondaryEditing = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="skill-chip-shared-config">
|
||||
<section class="mod-secondary-pool-registry">
|
||||
<div class="selection-head"><div><span>SHARED PRIMARY PROFILE</span><small>FOUR ABILITY-SCOPED STATS · ONE RANGE PER QUALITY</small></div><button type="button" class="row-action" onclick={openPrimary}>EDIT PRIMARY RANGES →</button></div>
|
||||
<div class="mod-secondary-capacity">
|
||||
{#each SKILL_CHIP_QUALITIES as quality}<span><b class={qualityClass(quality)}>{quality.toUpperCase()}</b><strong>{primaryProfiles.filter((profile) => profile.values[quality]).length}</strong><small>OF 4 CONFIGURED</small></span>{/each}
|
||||
<span class="pool-total"><b>CALCULATION</b><strong>—</strong><small>NOT YET DEFINED</small></span>
|
||||
</div>
|
||||
</section>
|
||||
<section class="mod-secondary-pool-registry">
|
||||
<div class="selection-head"><div><span>SHARED SECONDARY POOL</span><small>ONE CANONICAL ATTRIBUTE POOL FOR EVERY SKILL CHIP</small></div><button type="button" class="row-action" onclick={openSecondary}>EDIT SHARED POOL →</button></div>
|
||||
<div class="mod-secondary-capacity">
|
||||
{#each SKILL_CHIP_QUALITIES as quality}<span><b class={qualityClass(quality)}>{quality.toUpperCase()}</b><strong>{SKILL_CHIP_SECONDARY_CAPACITY[quality]}</strong><small>SECONDARY {SKILL_CHIP_SECONDARY_CAPACITY[quality] === 1 ? 'ROLL' : 'ROLLS'}</small></span>{/each}
|
||||
<span class="pool-total"><b>ATTRIBUTE POOL</b><strong>{secondaryPool.length}</strong><small>SHARED OPTIONS</small></span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{#if primaryEditing}
|
||||
<div class="dashboard-editor skill-chip-profile-editor">
|
||||
<div class="editor-title"><div><p class="eyebrow">SHARED CONFIGURATION</p><h3>SKILL CHIP PRIMARY RANGES</h3></div><button aria-label="Close primary profile editor" onclick={() => primaryEditing = false}>×</button></div>
|
||||
<PendingForm action="?/savePrimaryProfile">
|
||||
<input type="hidden" name="id" value="skill-chip-primary-profile"/><input type="hidden" name="name" value="Shared Skill Chip Primary Profile"/><input type="hidden" name="baseProfiles" value={JSON.stringify(primaryProfileBase)}/><input type="hidden" name="profiles" value={JSON.stringify(primaryDraft)}/>
|
||||
<p class="console-note">Every Skill Chip rolls one of these four ability-scoped primary stats. Configure the direct minimum and maximum for each quality. Current combat calculations do not apply these values.</p>
|
||||
<div class="skill-chip-profile-options fixed">
|
||||
{#each primaryDraft as profile}
|
||||
<div class="skill-chip-profile-option"><div><strong>{stats.find((item) => item.id === profile.statId)?.name ?? 'UNKNOWN ATTRIBUTE'}</strong><small>{stats.find((item) => item.id === profile.statId)?.unit || 'FLAT'} · ABILITY SCOPED</small></div>{#each SKILL_CHIP_QUALITIES as quality}<label><b class={qualityClass(quality)}>{quality.toUpperCase()}</b><span><input type="number" min="0" step="any" bind:value={profile.values[quality].min} aria-label={`${quality} ${stats.find((item) => item.id === profile.statId)?.name ?? 'attribute'} minimum`} required/><i>–</i><input type="number" min="0" step="any" bind:value={profile.values[quality].max} aria-label={`${quality} ${stats.find((item) => item.id === profile.statId)?.name ?? 'attribute'} maximum`} required/></span></label>{/each}</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if primaryError}<p class="error">{primaryError}</p>{/if}
|
||||
<div class="form-actions"><button type="button" class="secondary" onclick={() => primaryEditing = false}>CANCEL</button><button class="primary">SAVE PRIMARY PROFILE →</button></div>
|
||||
</PendingForm>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if secondaryEditing}
|
||||
<div class="dashboard-editor skill-chip-profile-editor">
|
||||
<div class="editor-title"><div><p class="eyebrow">SHARED CONFIGURATION</p><h3>SKILL CHIP SECONDARY POOL</h3></div><button aria-label="Close secondary pool editor" onclick={() => secondaryEditing = false}>×</button></div>
|
||||
<PendingForm action="?/saveSecondaryPool">
|
||||
<input type="hidden" name="id" value="skill-chip-secondary-pool"/><input type="hidden" name="name" value="Shared Skill Chip Secondary Pool"/><input type="hidden" name="baseProfiles" value={JSON.stringify(secondaryPool)}/><input type="hidden" name="profiles" value={JSON.stringify(secondaryDraft)}/>
|
||||
<p class="console-note">Standard has no secondary roll. Specialized selects one option; Superior and High-end select two distinct options. Skill Chips have no item level, so each quality uses one direct range.</p>
|
||||
<div class="selection-head pool-editor-head"><div><span>AVAILABLE ATTRIBUTES</span><small>SPECIALIZED · SUPERIOR · HIGH-END RANGES</small></div><button type="button" class="row-action" onclick={() => secondaryDraft = [...secondaryDraft, blankSecondary()]} disabled={secondaryDraft.length >= 50}>ADD ATTRIBUTE +</button></div>
|
||||
<div class="skill-chip-profile-options">
|
||||
{#each secondaryDraft as profile, index}
|
||||
<div class="skill-chip-profile-option selectable"><strong>{String(index + 1).padStart(2, '0')}</strong><SearchableAssignment items={stats} mode="single" compact subjectLabel="POSSIBLE ATTRIBUTE" searchPlaceholder="Search attributes…" excludedIds={secondaryDraft.filter((_, itemIndex) => itemIndex !== index).map((item) => item.statId)} bind:value={profile.statId}/>{#each SKILL_CHIP_SECONDARY_QUALITIES as quality}<label><b class={qualityClass(quality)}>{quality.toUpperCase()}</b><span><input type="number" min="0" step="any" bind:value={profile.values[quality].min} aria-label={`${quality} ${stats.find((item) => item.id === profile.statId)?.name ?? `option ${index + 1}`} minimum`} required/><i>–</i><input type="number" min="0" step="any" bind:value={profile.values[quality].max} aria-label={`${quality} ${stats.find((item) => item.id === profile.statId)?.name ?? `option ${index + 1}`} maximum`} required/></span></label>{/each}<button type="button" aria-label={`Remove secondary option ${index + 1}`} onclick={() => secondaryDraft = secondaryDraft.filter((_, itemIndex) => itemIndex !== index)} disabled={secondaryDraft.length <= 2}>×</button></div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if secondaryError}<p class="error">{secondaryError}</p>{/if}
|
||||
<div class="form-actions"><button type="button" class="secondary" onclick={() => secondaryEditing = false}>CANCEL</button><button class="primary">SAVE SHARED POOL →</button></div>
|
||||
</PendingForm>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
slot,
|
||||
iconUrl,
|
||||
decorative = true,
|
||||
size = 24
|
||||
}: { slot: string; iconUrl?: string | null; decorative?: boolean; size?: number } = $props();
|
||||
</script>
|
||||
|
||||
{#if iconUrl}
|
||||
<img
|
||||
class="weapon-mod-slot-icon"
|
||||
src={iconUrl}
|
||||
alt={decorative ? '' : `${slot} Weapon Mod slot`}
|
||||
loading="lazy"
|
||||
width={size}
|
||||
height={size}
|
||||
style={`--weapon-mod-slot-icon-size: ${size}px`}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
img {
|
||||
width: var(--weapon-mod-slot-icon-size);
|
||||
height: var(--weapon-mod-slot-icon-size);
|
||||
flex: 0 0 var(--weapon-mod-slot-icon-size);
|
||||
object-fit: contain;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,212 @@
|
||||
<script lang="ts">
|
||||
import IconUploadField from '$lib/components/IconUploadField.svelte';
|
||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||
import WeaponModSlotIcon from '$lib/components/WeaponModSlotIcon.svelte';
|
||||
|
||||
type SlotIcon = {
|
||||
id: string;
|
||||
name: string;
|
||||
iconUrl: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
let {
|
||||
slots,
|
||||
error = '',
|
||||
saved = false,
|
||||
resultToken = ''
|
||||
}: {
|
||||
slots: SlotIcon[];
|
||||
error?: string;
|
||||
saved?: boolean;
|
||||
resultToken?: string;
|
||||
} = $props();
|
||||
|
||||
let editing = $state<SlotIcon | null>(null);
|
||||
let handledResultToken = $state('');
|
||||
|
||||
$effect(() => {
|
||||
if (saved && resultToken && resultToken !== handledResultToken) {
|
||||
handledResultToken = resultToken;
|
||||
editing = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="slot-icon-registry" aria-labelledby="slot-icon-registry-title">
|
||||
<div class="selection-head">
|
||||
<div>
|
||||
<span id="slot-icon-registry-title">SHARED MOD SLOT ICONS</span>
|
||||
<small>ONE CANONICAL ICON FOR EACH WEAPON MOD SLOT</small>
|
||||
</div>
|
||||
<b>{slots.every((slot) => slot.iconUrl) ? 'READY' : 'CONFIG REQUIRED'}</b>
|
||||
</div>
|
||||
<div class="slot-icon-grid">
|
||||
{#each slots as slot}
|
||||
<button
|
||||
type="button"
|
||||
class:configured={Boolean(slot.iconUrl)}
|
||||
aria-pressed={editing?.id === slot.id}
|
||||
onclick={() => editing = slot}
|
||||
>
|
||||
<span class="slot-icon-preview">
|
||||
{#if slot.iconUrl}
|
||||
<WeaponModSlotIcon slot={slot.name} iconUrl={slot.iconUrl}/>
|
||||
{:else}
|
||||
<i aria-hidden="true">?</i>
|
||||
{/if}
|
||||
</span>
|
||||
<span><strong>{slot.name}</strong><small>{slot.iconUrl ? 'ICON REGISTERED' : 'ICON REQUIRED'}</small></span>
|
||||
<b>EDIT</b>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if editing}
|
||||
<section class="dashboard-editor slot-icon-editor" aria-labelledby="slot-icon-editor-title">
|
||||
<div class="editor-title">
|
||||
<div><p class="eyebrow">SHARED MOD SLOT ARTWORK</p><h3 id="slot-icon-editor-title">{editing.name.toUpperCase()}</h3></div>
|
||||
<button aria-label="Close slot icon editor" onclick={() => editing = null}>×</button>
|
||||
</div>
|
||||
<PendingForm action="?/saveSlotIcon" enctype="multipart/form-data">
|
||||
<input type="hidden" name="id" value={editing.id}/>
|
||||
<input type="hidden" name="name" value={`${editing.name} Slot`}/>
|
||||
<input type="hidden" name="slotType" value={editing.name}/>
|
||||
<input type="hidden" name="baseUpdatedAt" value={editing.updatedAt}/>
|
||||
<div class="slot-icon-editor-body">
|
||||
<div class="slot-icon-editor-identity">
|
||||
<span class="slot-icon-preview large">
|
||||
{#if editing.iconUrl}
|
||||
<WeaponModSlotIcon slot={editing.name} iconUrl={editing.iconUrl} size={56}/>
|
||||
{:else}
|
||||
<i aria-hidden="true">?</i>
|
||||
{/if}
|
||||
</span>
|
||||
<div><small>CANONICAL SLOT</small><strong>{editing.name}</strong><p>Weapon cards use this artwork anywhere the slot is available.</p></div>
|
||||
</div>
|
||||
<IconUploadField currentUrl={editing.iconUrl} label={`${editing.name.toUpperCase()} SLOT ICON`} resetToken={`${editing.id}:${editing.updatedAt}`}/>
|
||||
</div>
|
||||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
||||
<div class="form-actions"><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary">SAVE SLOT ICON →</button></div>
|
||||
</PendingForm>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.slot-icon-registry {
|
||||
margin: 10px 0;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--shd-border);
|
||||
border-left: 2px solid var(--shd-info);
|
||||
background: #0d1011;
|
||||
}
|
||||
.slot-icon-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 7px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.slot-icon-grid > button {
|
||||
min-height: 62px;
|
||||
display: grid;
|
||||
grid-template-columns: 38px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--shd-border);
|
||||
background: #101415;
|
||||
color: var(--shd-text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.slot-icon-grid > button:hover,
|
||||
.slot-icon-grid > button:focus-visible,
|
||||
.slot-icon-grid > button[aria-pressed='true'] {
|
||||
border-color: var(--shd-orange);
|
||||
background: #191712;
|
||||
}
|
||||
.slot-icon-grid > button:focus-visible {
|
||||
outline: 2px solid var(--shd-orange);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.slot-icon-grid > button > span:nth-child(2) {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
.slot-icon-grid strong,
|
||||
.slot-icon-grid small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.slot-icon-grid strong {
|
||||
font-size: var(--shd-type-label);
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
.slot-icon-grid small,
|
||||
.slot-icon-grid > button > b {
|
||||
color: var(--shd-text-muted);
|
||||
font-size: var(--shd-type-micro);
|
||||
letter-spacing: .07em;
|
||||
}
|
||||
.slot-icon-grid > button.configured small,
|
||||
.slot-icon-grid > button.configured > b {
|
||||
color: var(--shd-info);
|
||||
}
|
||||
.slot-icon-preview {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid #313839;
|
||||
background: #090b0c;
|
||||
}
|
||||
.slot-icon-preview i {
|
||||
color: #586164;
|
||||
font-size: 20px;
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
}
|
||||
.slot-icon-editor {
|
||||
border-top-color: var(--shd-info);
|
||||
}
|
||||
.slot-icon-editor-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, .7fr) minmax(360px, 1.3fr);
|
||||
gap: 12px;
|
||||
}
|
||||
.slot-icon-editor-identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--shd-border);
|
||||
background: #0d1011;
|
||||
}
|
||||
.slot-icon-preview.large {
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
flex: 0 0 76px;
|
||||
}
|
||||
.slot-icon-editor-identity > div {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
.slot-icon-editor-identity small,
|
||||
.slot-icon-editor-identity p {
|
||||
color: var(--shd-text-muted);
|
||||
font-size: var(--shd-type-telemetry);
|
||||
}
|
||||
.slot-icon-editor-identity p {
|
||||
margin: 0;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.slot-icon-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.slot-icon-editor-body { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.slot-icon-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
@@ -37,20 +37,20 @@
|
||||
&& Math.floor(Number(active.split(':')[1]) / 3) === Number(cell.id.split(':')[1]));
|
||||
|
||||
const issueLabel = (issue: string) => {
|
||||
if (/not configured|required/i.test(issue)) return 'ACTION REQUIRED';
|
||||
if (/incomplete/i.test(issue)) return 'DATA INCOMPLETE';
|
||||
return 'CHECK CONFIGURATION';
|
||||
if (/not configured|required|select|choose/i.test(issue)) return 'REQUIRED';
|
||||
if (/incomplete/i.test(issue)) return 'INCOMPLETE';
|
||||
return 'CHECK';
|
||||
};
|
||||
</script>
|
||||
|
||||
<section class="loadout-matrix" aria-label="Loadout matrix">
|
||||
<header>
|
||||
<span>LOADOUT MATRIX</span>
|
||||
<b class:ready={configured}>{configured ? 'COMBAT READY' : 'CONFIGURATION INCOMPLETE'}</b>
|
||||
<span>LOADOUT</span>
|
||||
<b class:ready={configured}>{configured ? 'COMPLETE' : 'INCOMPLETE'}</b>
|
||||
</header>
|
||||
|
||||
<div class="matrix-section doctrine-section">
|
||||
<small class="section-label">DOCTRINE · ROOT CHOICE</small>
|
||||
<small class="section-label">SPECIALIZATION FOCUS</small>
|
||||
<button
|
||||
type="button"
|
||||
class="doctrine-cell"
|
||||
@@ -60,27 +60,15 @@
|
||||
onclick={() => onselect(doctrine.id)}
|
||||
>
|
||||
<ItemIcon src={doctrine.iconUrl} alt="" className="matrix-icon" />
|
||||
<span><small>{doctrine.label}</small><strong>{doctrine.name}</strong><em>{doctrine.summary}</em>{#if doctrine.issue}<mark title={doctrine.issue} aria-hidden="true">{issueLabel(doctrine.issue)}</mark><span class="sr-only">{doctrine.issue}</span>{/if}</span>
|
||||
<span><small>{doctrine.label}</small><strong>{doctrine.name}</strong><em class:visible={selected(doctrine)}>{doctrine.summary}</em>{#if doctrine.issue}<mark title={doctrine.issue} aria-hidden="true">{issueLabel(doctrine.issue)}</mark><span class="sr-only">{doctrine.issue}</span>{/if}</span>
|
||||
<i aria-hidden="true"></i>
|
||||
</button>
|
||||
<div class="supply-line"><span>SUPPLIES 3 ABILITIES</span></div>
|
||||
<div class="supply-line"><span>ABILITIES</span></div>
|
||||
<div class="ability-cells">
|
||||
{#each abilities as cell}
|
||||
<button type="button" class:active={selected(cell)} class:needs-attention={Boolean(cell.issue)} aria-pressed={selected(cell)} onclick={() => onselect(cell.id)}>
|
||||
<ItemIcon src={cell.iconUrl} alt="" className="matrix-icon" />
|
||||
<span><small>{cell.label}</small><strong>{cell.name}</strong><em>{cell.summary}</em>{#if cell.issue}<mark title={cell.issue} aria-hidden="true">{issueLabel(cell.issue)}</mark><span class="sr-only">{cell.issue}</span>{/if}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="matrix-section">
|
||||
<small class="section-label">WEAPONS · PRIMARY + SECONDARY</small>
|
||||
<div class="equipment-cells">
|
||||
{#each weapons as cell}
|
||||
<button type="button" class:active={selected(cell)} class:needs-attention={Boolean(cell.issue)} aria-pressed={selected(cell)} onclick={() => onselect(cell.id)}>
|
||||
<ItemIcon src={cell.iconUrl} alt="" className="matrix-icon" />
|
||||
<span><small>{cell.label}</small><strong>{cell.name}</strong><em>{cell.summary}</em>{#if cell.issue}<mark title={cell.issue} aria-hidden="true">{issueLabel(cell.issue)}</mark><span class="sr-only">{cell.issue}</span>{/if}</span>
|
||||
<span><small>{cell.label}</small><strong>{cell.name}</strong><em class:visible={selected(cell)}>{cell.summary}</em>{#if cell.issue}<mark title={cell.issue} aria-hidden="true">{issueLabel(cell.issue)}</mark><span class="sr-only">{cell.issue}</span>{/if}</span>
|
||||
<i aria-hidden="true"></i>
|
||||
</button>
|
||||
{/each}
|
||||
@@ -88,12 +76,25 @@
|
||||
</div>
|
||||
|
||||
<div class="matrix-section">
|
||||
<small class="section-label">GEAR · 6 POSITIONS</small>
|
||||
<small class="section-label">WEAPONS</small>
|
||||
<div class="equipment-cells">
|
||||
{#each weapons as cell}
|
||||
<button type="button" class:active={selected(cell)} class:needs-attention={Boolean(cell.issue)} aria-pressed={selected(cell)} onclick={() => onselect(cell.id)}>
|
||||
<ItemIcon src={cell.iconUrl} alt="" className="matrix-icon" />
|
||||
<span><small>{cell.label}</small><strong>{cell.name}</strong><em class:visible={selected(cell)}>{cell.summary}</em>{#if cell.issue}<mark title={cell.issue} aria-hidden="true">{issueLabel(cell.issue)}</mark><span class="sr-only">{cell.issue}</span>{/if}</span>
|
||||
<i aria-hidden="true"></i>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="matrix-section">
|
||||
<small class="section-label">GEAR</small>
|
||||
<div class="equipment-cells gear-cells">
|
||||
{#each gear as cell}
|
||||
<button type="button" class:active={selected(cell)} class:needs-attention={Boolean(cell.issue)} aria-pressed={selected(cell)} onclick={() => onselect(cell.id)}>
|
||||
<ItemIcon src={cell.iconUrl} alt="" className="matrix-icon" />
|
||||
<span><small>{cell.label}</small><strong>{cell.name}</strong><em>{cell.summary}</em>{#if cell.issue}<mark title={cell.issue} aria-hidden="true">{issueLabel(cell.issue)}</mark><span class="sr-only">{cell.issue}</span>{/if}</span>
|
||||
<span><small>{cell.label}</small><strong>{cell.name}</strong><em class:visible={selected(cell)}>{cell.summary}</em>{#if cell.issue}<mark title={cell.issue} aria-hidden="true">{issueLabel(cell.issue)}</mark><span class="sr-only">{cell.issue}</span>{/if}</span>
|
||||
<i aria-hidden="true"></i>
|
||||
</button>
|
||||
{/each}
|
||||
@@ -101,10 +102,10 @@
|
||||
</div>
|
||||
|
||||
<div class="matrix-section protocol-section">
|
||||
<small class="section-label">AGENT SYSTEM · ONE OS PROTOCOL</small>
|
||||
<small class="section-label">OS PROTOCOL</small>
|
||||
<button type="button" class:active={selected(protocol)} class:needs-attention={Boolean(protocol.issue)} aria-pressed={selected(protocol)} onclick={() => onselect(protocol.id)}>
|
||||
<ItemIcon src={protocol.iconUrl} alt="" className="matrix-icon" />
|
||||
<span><small>{protocol.label}</small><strong>{protocol.name}</strong><em>{protocol.summary}</em>{#if protocol.issue}<mark title={protocol.issue} aria-hidden="true">{issueLabel(protocol.issue)}</mark><span class="sr-only">{protocol.issue}</span>{/if}</span>
|
||||
<span><small>{protocol.label}</small><strong>{protocol.name}</strong><em class:visible={selected(protocol)}>{protocol.summary}</em>{#if protocol.issue}<mark title={protocol.issue} aria-hidden="true">{issueLabel(protocol.issue)}</mark><span class="sr-only">{protocol.issue}</span>{/if}</span>
|
||||
<i aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -112,21 +113,20 @@
|
||||
|
||||
<style>
|
||||
.loadout-matrix{min-width:0;border:1px solid var(--shd-border);background:var(--shd-surface)}
|
||||
.loadout-matrix>header{min-height:40px;display:flex;align-items:center;justify-content:space-between;gap:8px;padding:0 10px;border-bottom:1px solid var(--shd-border);background:#0c0f10;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.08em}
|
||||
.loadout-matrix>header{height:auto;min-height:40px;position:static;display:flex;align-items:center;justify-content:space-between;gap:8px;padding:0 10px;border-bottom:1px solid var(--shd-border);background:#0c0f10;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.08em}
|
||||
.loadout-matrix>header b{color:var(--shd-warning);font-size:9px;text-align:right}.loadout-matrix>header b.ready{color:var(--shd-success)}
|
||||
.matrix-section{padding:5px;border-bottom:1px solid var(--shd-border)}.matrix-section:last-child{border-bottom:0}
|
||||
.section-label{display:block;padding:2px 3px 5px;color:var(--shd-text-dim);font-size:9px;font-weight:900;letter-spacing:.09em}
|
||||
.doctrine-cell,.equipment-cells button,.ability-cells button,.protocol-section>button{min-width:0;display:grid;grid-template-columns:34px minmax(0,1fr) 3px;align-items:center;gap:7px;padding:7px;border:1px solid var(--shd-border-soft);background:var(--shd-builder-canvas);color:var(--shd-text);text-align:left}
|
||||
.doctrine-cell{width:100%;min-height:76px;grid-template-columns:42px minmax(0,1fr) 3px}.doctrine-cell :global(.matrix-icon){width:40px;height:40px}
|
||||
:global(.matrix-icon){width:30px;height:30px;object-fit:contain}
|
||||
button>span{min-width:0;display:grid;gap:2px}button small,button em{overflow:hidden;color:var(--shd-text-dim);font-size:9px;font-style:normal;letter-spacing:.04em;text-overflow:ellipsis;white-space:nowrap}button strong{overflow:hidden;font-size:11px;text-overflow:ellipsis;white-space:nowrap}button>i{align-self:stretch;background:var(--quality-high-end)}
|
||||
button>span{min-width:0;display:grid;gap:2px}button small,button em{overflow:hidden;color:var(--shd-text-dim);font-size:9px;font-style:normal;letter-spacing:.04em;text-overflow:ellipsis;white-space:nowrap}button em{display:none}button em.visible{display:block}button strong{overflow:hidden;font-size:11px;text-overflow:ellipsis;white-space:nowrap}button>i{align-self:stretch;background:var(--quality-high-end)}
|
||||
button:hover,button:focus-visible{border-color:var(--shd-border-strong);background:var(--shd-surface-active);outline:none}button.active{border-color:var(--shd-builder-selection-border);background:var(--shd-builder-selection);box-shadow:inset 2px 0 var(--shd-orange)}
|
||||
button.needs-attention{border-bottom-color:color-mix(in srgb,var(--shd-danger) 68%,var(--shd-border-soft))}button.needs-attention>i{background:var(--shd-danger)}button mark{width:fit-content;max-width:100%;overflow:hidden;margin-top:2px;padding:2px 4px;border:1px solid color-mix(in srgb,var(--shd-danger) 58%,transparent);background:#1d1212;color:var(--shd-danger);font-size:8px;font-weight:900;letter-spacing:.07em;line-height:1.2;text-overflow:ellipsis;text-transform:uppercase;white-space:nowrap}
|
||||
.supply-line{height:22px;display:grid;place-items:center;background:linear-gradient(90deg,transparent 49.7%,var(--shd-orange) 49.7% 50.3%,transparent 50.3%)}.supply-line span{padding:0 6px;background:var(--shd-surface);color:var(--shd-orange);font-size:8px;font-weight:900;letter-spacing:.08em}
|
||||
.ability-cells{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:3px}.ability-cells button{min-height:68px;grid-template-columns:25px minmax(0,1fr);padding:6px}.ability-cells :global(.matrix-icon){width:24px;height:24px}
|
||||
.ability-cells{display:grid;grid-template-columns:1fr;gap:3px}.ability-cells button{min-height:48px;grid-template-columns:25px minmax(0,1fr) 3px;padding:6px}.ability-cells :global(.matrix-icon){width:24px;height:24px}
|
||||
.equipment-cells{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:3px}.equipment-cells button{min-height:69px}.protocol-section>button{width:100%;min-height:68px}
|
||||
button:focus-visible{outline:2px solid var(--shd-focus-ring);outline-offset:2px}
|
||||
@media(max-width:1240px){.ability-cells{grid-template-columns:1fr}.ability-cells button{min-height:48px}}
|
||||
@media(max-width:800px){.ability-cells{grid-template-columns:repeat(3,minmax(0,1fr))}}
|
||||
@media(max-width:520px){.ability-cells{grid-template-columns:1fr}.equipment-cells{grid-template-columns:1fr 1fr}}
|
||||
@media(max-width:800px) and (min-width:651px){.ability-cells{grid-template-columns:repeat(3,minmax(0,1fr))}.ability-cells button{min-height:68px}}
|
||||
@media(max-width:520px){.ability-cells{grid-template-columns:1fr}.equipment-cells{grid-template-columns:1fr 1fr}.gear-cells{grid-template-columns:1fr}}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
<script lang="ts">
|
||||
import GameIcon from '$lib/components/GameIcon.svelte';
|
||||
import type { GameIconName } from '$lib/game-icons';
|
||||
import type { LoadoutSummary } from '$lib/builder/loadout-summary';
|
||||
|
||||
let {
|
||||
summary,
|
||||
attributes,
|
||||
talents = [],
|
||||
headingId,
|
||||
layout = 'rail'
|
||||
}: {
|
||||
summary: LoadoutSummary;
|
||||
attributes: any[];
|
||||
talents?: any[];
|
||||
headingId: string;
|
||||
layout?: 'rail' | 'page';
|
||||
} = $props();
|
||||
|
||||
const attribute = (id: string) => attributes.find((item) => item.id === id);
|
||||
const talent = (id: string) => talents.find((item) => item.id === id);
|
||||
const unitValue = (value: unknown, statId: string) => {
|
||||
const numeric = Number(value);
|
||||
const rendered = Number.isInteger(numeric)
|
||||
? numeric.toLocaleString()
|
||||
: numeric.toFixed(2).replace(/\.?0+$/, '');
|
||||
const unit = attribute(statId)?.unit ?? '';
|
||||
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`;
|
||||
};
|
||||
const talentCooldown = (id: string) => {
|
||||
const value = talent(id)?.cooldown_seconds;
|
||||
if (value === null || value === undefined || value === '') return '';
|
||||
return `Cooldown: ${value} ${Number(value) === 1 ? 'second' : 'seconds'}`;
|
||||
};
|
||||
</script>
|
||||
|
||||
<section class:page={layout === 'page'} class="loadout-known-stats" aria-labelledby={headingId}>
|
||||
<div class="summary-head">
|
||||
<div>
|
||||
<p class="eyebrow"><span></span> KNOWN STATS</p>
|
||||
<h2 id={headingId}>Agent stats</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-core">
|
||||
{#each ['Firepower', 'Toughness', 'Engineering'] as name}
|
||||
{@const stat = summary.stats.find((entry) => attribute(entry.id)?.name === name)}
|
||||
<div>
|
||||
<GameIcon name={name.toLowerCase() as GameIconName} decorative />
|
||||
<span>{name}<b>{stat ? unitValue(stat.value, stat.id) : '0'}</b></span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="summary-scroll">
|
||||
<div class="summary-section">
|
||||
<header><span>ATTRIBUTES</span><b>{summary.stats.length}</b></header>
|
||||
{#each summary.stats as stat}
|
||||
<div class="summary-stat">
|
||||
<span>{attribute(stat.id)?.name}<small>{attribute(stat.id)?.category} · {stat.sourceCount} SOURCE{stat.sourceCount === 1 ? '' : 'S'}</small></span>
|
||||
<b>{unitValue(stat.value, stat.id)}</b>
|
||||
</div>
|
||||
{:else}
|
||||
<p>Equip items to see known Attributes.</p>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="summary-section">
|
||||
<header><span>SET BONUSES</span><b>{summary.activeBonuses.length}</b></header>
|
||||
{#each summary.activeBonuses as bonus}
|
||||
<div class="summary-bonus">
|
||||
<span><b>{bonus.name}</b><small>{bonus.pieces}-PIECE · {bonus.count} EQUIPPED</small></span>
|
||||
<em>+{unitValue(bonus.value, bonus.statId)} {attribute(bonus.statId)?.name}</em>
|
||||
</div>
|
||||
{:else}
|
||||
<p>No active bonuses.</p>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="summary-section">
|
||||
<header><span>TALENTS</span><b>{summary.equippedTalents.length}</b></header>
|
||||
{#each summary.equippedTalents as equipped}
|
||||
<div class="summary-talent">
|
||||
<details open={layout === 'page'}>
|
||||
<summary><b>{equipped.custom?.name ?? talent(equipped.id)?.name ?? 'Talent'}</b><small>{equipped.source}{equipped.tierLabel ? ` · ${equipped.tierLabel}` : ''}</small></summary>
|
||||
<p>{equipped.description}</p>
|
||||
{#if equipped.id && talentCooldown(equipped.id)}<i class="talent-cooldown">{talentCooldown(equipped.id)}</i>{/if}
|
||||
</details>
|
||||
</div>
|
||||
{:else}
|
||||
<p>No Talents equipped.</p>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<p class="known-stats-note">Known values only. Advanced effects are planned.</p>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.loadout-known-stats{background:var(--shd-surface)}
|
||||
.summary-talent{grid-template-columns:1fr;cursor:default}
|
||||
.summary-talent details,.summary-talent summary{min-width:0}
|
||||
.summary-talent summary{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px;align-items:center;cursor:pointer;list-style:none}
|
||||
.summary-talent summary::-webkit-details-marker{display:none}
|
||||
.summary-talent summary:after{content:'+';grid-column:2;grid-row:1/3;color:var(--shd-orange);font-size:14px}
|
||||
.summary-talent details[open] summary:after{content:'-'}
|
||||
.summary-talent summary b,.summary-talent summary small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.summary-talent summary small{grid-column:1;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry)}
|
||||
.summary-talent details>p{margin:8px 0 0;color:var(--shd-text-muted);font:var(--shd-type-telemetry)/1.45 var(--shd-font-editorial)}
|
||||
.summary-talent summary:focus-visible{outline:2px solid var(--shd-focus-ring);outline-offset:3px}
|
||||
.known-stats-note{margin:0;padding:11px;border-top:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.45}
|
||||
.page{border:1px solid var(--shd-border);border-top:2px solid var(--shd-success)}
|
||||
.page .summary-scroll{max-height:none;display:grid;grid-template-columns:repeat(3,minmax(0,1fr))}
|
||||
.page .summary-section{border-left:1px solid var(--shd-border)}
|
||||
.page .summary-section:first-child{border-left:0}
|
||||
@media(max-width:900px){.page .summary-scroll{grid-template-columns:1fr}.page .summary-section{border-left:0;border-top:1px solid var(--shd-border)}.page .summary-section:first-child{border-top:0}}
|
||||
</style>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
headingId,
|
||||
variant = 'full',
|
||||
headingLevel = 'h3'
|
||||
}: {
|
||||
headingId: string;
|
||||
variant?: 'full' | 'compact';
|
||||
headingLevel?: 'h2' | 'h3';
|
||||
} = $props();
|
||||
|
||||
const capabilities = [
|
||||
{ label: 'WEAPON OUTPUT', detail: 'Hits and firing cycles' },
|
||||
{ label: 'SKILL SIMULATION', detail: 'Timing and target caps' },
|
||||
{ label: 'ENCOUNTER MODEL', detail: 'Duration and rotations' },
|
||||
{ label: 'CONDITIONAL EFFECTS', detail: 'Triggers and uptime' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<section class:compact={variant === 'compact'} class="planned-combat-model" aria-labelledby={headingId}>
|
||||
<header>
|
||||
<div><span>COMBAT MODEL</span>{#if headingLevel === 'h2'}<h2 id={headingId}>DPS calculations</h2>{:else}<h3 id={headingId}>DPS calculations</h3>{/if}</div>
|
||||
<b>PLANNED</b>
|
||||
</header>
|
||||
{#if variant === 'full'}
|
||||
<p>Known stats are live. Damage projections are planned.</p>
|
||||
{/if}
|
||||
<div class="planned-capabilities" aria-label="Planned combat-model capabilities">
|
||||
{#each capabilities as capability}
|
||||
<div><span><strong>{capability.label}</strong><small>{capability.detail}</small></span><em>PLANNED</em></div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.planned-combat-model{min-width:0;display:grid;grid-template-columns:minmax(0,1fr);gap:var(--shd-gap-md);padding:var(--shd-gap-md);overflow:hidden;border:1px solid var(--shd-model-border);border-top:2px solid var(--shd-info);background:var(--shd-model-surface)}
|
||||
.planned-combat-model>*{min-width:0}
|
||||
header{display:flex;align-items:start;justify-content:space-between;gap:var(--shd-gap-md);padding:0}
|
||||
header div{display:grid;gap:3px}header span{color:var(--shd-info);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1px}h2,h3{margin:0;color:var(--shd-text);font-size:var(--shd-type-body);letter-spacing:.7px;text-transform:uppercase}header b{padding:5px 7px;border:1px solid var(--shd-info);color:var(--shd-info);font-size:var(--shd-type-telemetry);letter-spacing:.8px;white-space:nowrap}
|
||||
p{max-width:72ch;margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.5}
|
||||
.planned-capabilities{width:100%;min-width:0;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));border:1px solid var(--shd-model-border)}
|
||||
.planned-capabilities>div{display:flex;align-items:center;justify-content:space-between;gap:var(--shd-gap-sm);min-width:0;padding:9px;border-top:1px solid var(--shd-model-border);border-left:1px solid var(--shd-model-border)}
|
||||
.planned-capabilities>div:nth-child(-n+2){border-top:0}.planned-capabilities>div:nth-child(odd){border-left:0}
|
||||
.planned-capabilities span{display:grid;gap:3px;min-width:0}.planned-capabilities strong,.planned-capabilities em{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.planned-capabilities small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.35}.planned-capabilities em{color:var(--shd-text-dim);font-style:normal;white-space:nowrap}
|
||||
.compact{gap:var(--shd-gap-sm);padding:var(--shd-gap-sm)}.compact .planned-capabilities{grid-template-columns:minmax(0,1fr)}.compact .planned-capabilities>div{border-left:0}.compact .planned-capabilities>div:nth-child(2){border-top:1px solid var(--shd-model-border)}
|
||||
@media(max-width:620px){header{display:grid}.planned-capabilities{grid-template-columns:1fr}.planned-capabilities>div{min-height:44px;border-left:0}.planned-capabilities>div:nth-child(2){border-top:1px solid var(--shd-model-border)}}
|
||||
</style>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script lang="ts">
|
||||
import ItemIcon from '$lib/components/ItemIcon.svelte';
|
||||
import type { TalentTier } from '$lib/builder/loadout';
|
||||
|
||||
let {
|
||||
label = 'TALENT TIER',
|
||||
name,
|
||||
talent,
|
||||
value = $bindable(1),
|
||||
onchange
|
||||
}: {
|
||||
label?: string;
|
||||
name: string;
|
||||
talent: { name: string; iconUrl?: string; tiers?: Array<{ tier: string }> };
|
||||
value?: TalentTier;
|
||||
onchange?: (tier: TalentTier) => void;
|
||||
} = $props();
|
||||
|
||||
const available = (tier: TalentTier) => talent.tiers?.some((entry) => entry.tier === (tier === 2 ? 'II' : 'I')) ?? false;
|
||||
const choose = (tier: TalentTier) => {
|
||||
value = tier;
|
||||
onchange?.(tier);
|
||||
};
|
||||
</script>
|
||||
|
||||
<fieldset class="talent-tier-choice">
|
||||
<legend>{label}</legend>
|
||||
<div>
|
||||
{#each [1, 2] as tier}
|
||||
<label class:tier-two={tier === 2}>
|
||||
<input type="radio" {name} value={tier} checked={value === tier} disabled={!available(tier as TalentTier)} onchange={() => choose(tier as TalentTier)}/>
|
||||
<span>
|
||||
<ItemIcon src={talent.iconUrl} alt="" className="talent-tier-icon"/>
|
||||
<small>TIER {tier === 2 ? 'II' : 'I'}</small>
|
||||
<b>T{tier}</b>
|
||||
<em>{available(tier as TalentTier) ? value === tier ? 'SELECTED' : 'SELECT' : 'UNAVAILABLE'}</em>
|
||||
</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<style>
|
||||
.talent-tier-choice{min-width:0;margin:8px 0 0;padding:0;border:0}
|
||||
.talent-tier-choice legend{margin-bottom:5px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.07em}
|
||||
.talent-tier-choice>div{display:grid;grid-template-columns:1fr 1fr;gap:5px}
|
||||
.talent-tier-choice label{min-width:0;cursor:pointer}
|
||||
.talent-tier-choice input{position:absolute;width:1px;height:1px;margin:0;opacity:0;pointer-events:none}
|
||||
.talent-tier-choice span{min-height:64px;display:grid;grid-template-columns:42px minmax(0,1fr) auto;grid-template-rows:auto auto;align-items:center;gap:1px 8px;padding:7px;border:1px solid var(--shd-border);background:var(--shd-builder-canvas);color:var(--shd-text);transition:var(--shd-transition)}
|
||||
.talent-tier-choice :global(.talent-tier-icon){grid-row:1/3;width:40px;height:40px;object-fit:contain}
|
||||
.talent-tier-choice .tier-two :global(.talent-tier-icon){filter:sepia(.3) saturate(1.45) hue-rotate(335deg) brightness(1.12)}
|
||||
.talent-tier-choice small{align-self:end;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.06em}
|
||||
.talent-tier-choice b{grid-column:3;grid-row:1/3;color:var(--shd-text-muted);font-size:18px}
|
||||
.talent-tier-choice em{align-self:start;color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-style:normal;font-weight:900}
|
||||
.talent-tier-choice label:hover span{border-color:var(--shd-border-strong);background:var(--shd-surface-active)}
|
||||
.talent-tier-choice input:checked+span{border-color:var(--shd-builder-selection-border);background:var(--shd-builder-selection);box-shadow:inset 0 -2px var(--shd-orange)}
|
||||
.talent-tier-choice input:checked+span b,.talent-tier-choice input:checked+span em{color:var(--shd-orange)}
|
||||
.talent-tier-choice input:focus-visible+span{outline:2px solid var(--shd-focus-ring);outline-offset:2px}
|
||||
.talent-tier-choice input:disabled+span{opacity:.38;cursor:not-allowed;filter:saturate(.35)}
|
||||
@media(max-width:430px){.talent-tier-choice span{grid-template-columns:36px minmax(0,1fr);}.talent-tier-choice :global(.talent-tier-icon){width:34px;height:34px}.talent-tier-choice b{display:none}}
|
||||
</style>
|
||||
@@ -9,6 +9,8 @@
|
||||
blankConditionalMechanicDraft,
|
||||
currentHealthPercentBandPredicate,
|
||||
eventFieldComparisonPredicate,
|
||||
mechanicTargetingForMode,
|
||||
mechanicTargetingMode,
|
||||
parseConditionalMechanicDraft,
|
||||
readCurrentHealthPercentBand,
|
||||
readEventFieldComparison,
|
||||
@@ -21,6 +23,7 @@
|
||||
type ConditionalMechanicPoolDraft,
|
||||
type EditablePredicateScalar,
|
||||
type EventFieldComparison,
|
||||
type MechanicTargetingMode,
|
||||
type PredicateComparisonOperator,
|
||||
type StatePoolThresholdComparison
|
||||
} from '$lib/calculations/mechanic-editor';
|
||||
@@ -182,7 +185,9 @@
|
||||
}
|
||||
|
||||
function eventPredicateOperatorOptions(clause: ConditionalMechanicClauseDraft) {
|
||||
return selectedEventPredicateField(clause)?.type === 'number'
|
||||
const field = selectedEventPredicateField(clause);
|
||||
if (field?.path === 'values.aiming') return predicateOperatorOptions.filter((option) => option.value === 'eq');
|
||||
return field?.type === 'number'
|
||||
? predicateOperatorOptions
|
||||
: predicateOperatorOptions.filter((option) => option.value === 'eq' || option.value === 'ne');
|
||||
}
|
||||
@@ -190,15 +195,21 @@
|
||||
function setClauseEvent(clauseIndex: number, event: string) {
|
||||
const nextEvent = event as ConditionalMechanicClauseDraft['event'];
|
||||
const clause = rule.clauses[clauseIndex];
|
||||
const outcomes = nextEvent === 'weapon_hit'
|
||||
? clause.outcomes
|
||||
: clause.outcomes.map((outcome) => outcome.targeting?.kind === 'event_target_area'
|
||||
? { ...outcome, targeting: undefined }
|
||||
: outcome);
|
||||
const fields = registeredEventFields(nextEvent);
|
||||
const comparison = readEventFieldComparison(clause.if);
|
||||
if (!comparison || !fields.length) {
|
||||
updateClause(clauseIndex, { event: nextEvent });
|
||||
updateClause(clauseIndex, { event: nextEvent, outcomes });
|
||||
return;
|
||||
}
|
||||
const field = fields[0];
|
||||
updateClause(clauseIndex, {
|
||||
event: nextEvent,
|
||||
outcomes,
|
||||
if: eventFieldComparisonPredicate({
|
||||
path: field.path,
|
||||
operator: 'eq',
|
||||
@@ -403,6 +414,69 @@
|
||||
updateClause(clauseIndex, { outcomes });
|
||||
}
|
||||
|
||||
function targetingOptions(clause: ConditionalMechanicClauseDraft, outcome: ConditionalMechanicOutcomeDraft) {
|
||||
if (outcome.scope === 'allies' || outcome.scope === 'self_and_allies') {
|
||||
return [
|
||||
{ value: 'none', label: 'NONE' },
|
||||
{ value: 'allies_within_radius', label: 'ALLIES IN RADIUS' }
|
||||
];
|
||||
}
|
||||
if (outcome.scope === 'target') {
|
||||
return [
|
||||
{ value: 'none', label: 'NONE' },
|
||||
{ value: 'enemies_within_radius', label: 'ENEMIES IN RADIUS · LEGACY' },
|
||||
...(outcome.kind === 'direct_damage' && clause.event === 'weapon_hit'
|
||||
? [{ value: 'event_target_explosion', label: 'HIT-TARGET EXPLOSION' }]
|
||||
: [])
|
||||
];
|
||||
}
|
||||
return [{ value: 'none', label: 'NONE' }];
|
||||
}
|
||||
|
||||
function setOutcomeScope(clauseIndex: number, outcomeIndex: number, scope: MechanicOutcomeScope) {
|
||||
const outcome = rule.clauses[clauseIndex].outcomes[outcomeIndex];
|
||||
const targeting = outcome.targeting;
|
||||
const compatible = !targeting
|
||||
|| (targeting.kind === 'event_target_area' && scope === 'target' && outcome.kind === 'direct_damage')
|
||||
|| (targeting.kind === 'within_radius' && targeting.population === 'enemies' && scope === 'target')
|
||||
|| (targeting.kind === 'within_radius' && targeting.population === 'allies' && (scope === 'allies' || scope === 'self_and_allies'));
|
||||
updateOutcome(clauseIndex, outcomeIndex, { scope, targeting: compatible ? targeting : undefined });
|
||||
}
|
||||
|
||||
function setOutcomeKind(clauseIndex: number, outcomeIndex: number, kind: ConditionalMechanicOutcomeDraft['kind']) {
|
||||
const outcome = rule.clauses[clauseIndex].outcomes[outcomeIndex];
|
||||
updateOutcome(clauseIndex, outcomeIndex, {
|
||||
kind,
|
||||
resource: kind === 'restoration' || kind === 'resource_cost' ? outcome.resource ?? 'health' : undefined,
|
||||
attributeId: kind === 'attribute_modifier' ? outcome.attributeId ?? '' : undefined,
|
||||
delivery: kind === 'restoration' ? outcome.delivery : undefined,
|
||||
durationSeconds: kind === 'restoration' ? outcome.durationSeconds : undefined,
|
||||
targeting: outcome.targeting?.kind === 'event_target_area' && kind !== 'direct_damage'
|
||||
? undefined
|
||||
: outcome.targeting
|
||||
});
|
||||
}
|
||||
|
||||
function setOutcomeTargeting(clauseIndex: number, outcomeIndex: number, mode: MechanicTargetingMode) {
|
||||
const outcome = rule.clauses[clauseIndex].outcomes[outcomeIndex];
|
||||
updateOutcome(clauseIndex, outcomeIndex, {
|
||||
targeting: mechanicTargetingForMode(mode, outcome.targeting),
|
||||
phase: mode === 'event_target_explosion' && outcome.phase !== 'on_event' && outcome.phase !== 'after_event'
|
||||
? 'after_event'
|
||||
: outcome.phase
|
||||
});
|
||||
}
|
||||
|
||||
function phaseOptions(outcome: ConditionalMechanicOutcomeDraft) {
|
||||
return [
|
||||
{ value: 'on_event', label: 'ON EVENT' },
|
||||
{ value: 'after_event', label: 'AFTER EVENT' },
|
||||
{ value: 'while_active', label: 'WHILE ACTIVE', disabled: outcome.targeting?.kind === 'event_target_area' },
|
||||
{ value: 'on_enter', label: 'ON ENTER', disabled: outcome.targeting?.kind === 'event_target_area' },
|
||||
{ value: 'on_exit', label: 'ON EXIT', disabled: outcome.targeting?.kind === 'event_target_area' }
|
||||
];
|
||||
}
|
||||
|
||||
function updateVariant(clauseIndex: number, outcomeIndex: number, variant: string, raw: string) {
|
||||
const outcome = rule.clauses[clauseIndex].outcomes[outcomeIndex];
|
||||
const value = Number(raw);
|
||||
@@ -684,6 +758,50 @@
|
||||
});
|
||||
}
|
||||
|
||||
function addAbilityDurationExtension(clauseIndex: number) {
|
||||
updateClause(clauseIndex, {
|
||||
mutations: [
|
||||
...rule.clauses[clauseIndex].mutations,
|
||||
{ kind: 'extend_duration', target: 'assigned_ability', seconds: 0.3 }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
function addCooldownOnMax(clauseIndex: number) {
|
||||
const stackPool = rule.pools.find((pool) => pool.max && pool.max > 0)?.key;
|
||||
const cooldownPool = rule.pools.find((pool) => pool.scope === 'agent' && pool.expiresAfterSeconds)?.key;
|
||||
if (!stackPool || !cooldownPool || stackPool === cooldownPool) return;
|
||||
updateClause(clauseIndex, {
|
||||
mutations: [
|
||||
...rule.clauses[clauseIndex].mutations,
|
||||
{ kind: 'start_cooldown_on_max', stackPool, cooldownPool }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
function updateAbilityDurationExtension(clauseIndex: number, mutationIndex: number, raw: string) {
|
||||
const mutations = [...rule.clauses[clauseIndex].mutations];
|
||||
const seconds = Number(raw);
|
||||
mutations[mutationIndex] = {
|
||||
kind: 'extend_duration',
|
||||
target: 'assigned_ability',
|
||||
seconds: Number.isFinite(seconds) ? seconds : 0
|
||||
};
|
||||
updateClause(clauseIndex, { mutations });
|
||||
}
|
||||
|
||||
function updateCooldownOnMax(
|
||||
clauseIndex: number,
|
||||
mutationIndex: number,
|
||||
patch: { stackPool?: string; cooldownPool?: string }
|
||||
) {
|
||||
const mutations = [...rule.clauses[clauseIndex].mutations];
|
||||
const current = mutations[mutationIndex];
|
||||
if (current.kind !== 'start_cooldown_on_max') return;
|
||||
mutations[mutationIndex] = { ...current, ...patch };
|
||||
updateClause(clauseIndex, { mutations });
|
||||
}
|
||||
|
||||
function updateMutation(clauseIndex: number, mutationIndex: number, kind: 'add' | 'set' | 'consume' | 'clear', pool?: string, value?: number) {
|
||||
const mutations = [...rule.clauses[clauseIndex].mutations];
|
||||
const current = mutations[mutationIndex];
|
||||
@@ -793,7 +911,7 @@
|
||||
|
||||
<section class="scenario-panel">
|
||||
<header><div><span>BUILDER CONTROLS</span><strong>SCENARIO ASSUMPTIONS</strong></div><button type="button" class="secondary compact-action" onclick={addScenarioInput}>ADD CONTROL +</button></header>
|
||||
<p>These controls appear in a build’s advanced mechanic settings. Maximum Potential uses the best-case value; Custom Scenario uses the Agent’s saved value.</p>
|
||||
<p>These controls are retained for the planned advanced build model. Maximum Potential will use the best-case value; Custom Scenario will use the Agent’s saved value.</p>
|
||||
{#each rule.scenarioInputs as input, index}
|
||||
<div class="scenario-row">
|
||||
<label class="field">LABEL<input value={input.label} oninput={(event) => updateScenarioInput(index,{label:event.currentTarget.value,key:slug(event.currentTarget.value)})}/></label>
|
||||
@@ -981,6 +1099,20 @@
|
||||
{:else if conditionModel(clause) === 'advanced'}
|
||||
<p class="advanced-condition-note">This saved condition remains active and is preserved unchanged. Use the generated mechanic preview to inspect it.</p>
|
||||
{/if}
|
||||
{#if allowAssignedAbility}
|
||||
<div class="ability-proc-controls">
|
||||
<SegmentedChoice
|
||||
legend="ASSIGNED ABILITY"
|
||||
name={`${idPrefix}-assigned-ability-${clauseIndex}`}
|
||||
options={[{value:'false',label:'ANY STATE'},{value:'true',label:'MUST BE ACTIVE'}]}
|
||||
value={String(clause.requiresAssignedAbilityActive ?? false)}
|
||||
onchange={(values) => updateClause(clauseIndex,{requiresAssignedAbilityActive:values[0] === 'true' ? true : undefined})}
|
||||
/>
|
||||
<label class="field">PROC CHANCE · %<input type="number" min="0" max="100" step="any" value={clause.procChancePercent ?? ''} oninput={(event) => updateClause(clauseIndex,{procChancePercent:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/><small>Rolls after conditions and cooldown checks, before mutations.</small></label>
|
||||
</div>
|
||||
{:else}
|
||||
<label class="field proc-chance-field">PROC CHANCE · %<input type="number" min="0" max="100" step="any" value={clause.procChancePercent ?? ''} oninput={(event) => updateClause(clauseIndex,{procChancePercent:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/><small>Rolls after conditions and cooldown checks, before mutations.</small></label>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="reducer-controls">
|
||||
<SegmentedChoice
|
||||
@@ -1007,38 +1139,53 @@
|
||||
</div>
|
||||
|
||||
<div class="mutation-panel">
|
||||
<div class="outcome-heading"><div><span>STATE CHANGES</span><strong>{clause.mutations.length}</strong></div><button type="button" class="secondary compact-action" onclick={() => addMutation(clauseIndex)} disabled={!rule.pools.length}>ADD STATE CHANGE +</button></div>
|
||||
<div class="outcome-heading"><div><span>STATE CHANGES</span><strong>{clause.mutations.length}</strong></div><div class="mutation-actions"><button type="button" class="secondary compact-action" onclick={() => addMutation(clauseIndex)} disabled={!rule.pools.length}>ADD STATE CHANGE +</button>{#if allowAssignedAbility}<button type="button" class="secondary compact-action" onclick={() => addAbilityDurationExtension(clauseIndex)}>EXTEND ABILITY +</button>{/if}<button type="button" class="secondary compact-action" onclick={() => addCooldownOnMax(clauseIndex)} disabled={rule.pools.filter((pool) => pool.max && pool.max > 0).length < 2}>ADD MAX COOLDOWN +</button></div></div>
|
||||
{#each clause.mutations as mutation, mutationIndex}
|
||||
<div class="mutation-row">
|
||||
<SegmentedChoice
|
||||
legend="OPERATION"
|
||||
name={`${idPrefix}-mutation-kind-${clauseIndex}-${mutationIndex}`}
|
||||
options={[{value:'add',label:'ADD'},{value:'set',label:'SET'},{value:'consume',label:'CONSUME'},{value:'clear',label:'CLEAR'}]}
|
||||
value={mutation.kind === 'none' || mutation.kind === 'derive' || mutation.kind === 'transfer' ? 'add' : mutation.kind}
|
||||
onchange={(values) => updateMutation(clauseIndex,mutationIndex,values[0] as 'add'|'set'|'consume'|'clear')}
|
||||
/>
|
||||
<SegmentedChoice
|
||||
legend="STATE POOL"
|
||||
name={`${idPrefix}-mutation-pool-${clauseIndex}-${mutationIndex}`}
|
||||
options={rule.pools.map((pool) => ({value:pool.key,label:pool.label.toUpperCase()}))}
|
||||
value={'pool' in mutation ? mutation.pool : rule.pools[0]?.key ?? ''}
|
||||
columns={Math.min(4,Math.max(1,rule.pools.length))}
|
||||
onchange={(values) => updateMutation(clauseIndex,mutationIndex,mutation.kind === 'clear' ? 'clear' : mutation.kind === 'set' || mutation.kind === 'consume' ? mutation.kind : 'add',values[0])}
|
||||
/>
|
||||
{#if mutation.kind !== 'clear'}
|
||||
<label class="field">VALUE<input type="number" step="any" value={'value' in mutation && typeof mutation.value === 'number' ? mutation.value : 1} oninput={(event) => updateMutation(clauseIndex,mutationIndex,mutation.kind === 'set' || mutation.kind === 'consume' ? mutation.kind : 'add',undefined,Number(event.currentTarget.value))}/></label>
|
||||
{/if}
|
||||
{#if 'pool' in mutation && rule.pools.find((pool) => pool.key === mutation.pool)?.scope === 'target'}
|
||||
{#if mutation.kind === 'extend_duration'}
|
||||
<div class="mutation-row ability-mutation-row">
|
||||
<div class="branch-readout"><span>OPERATION</span><strong>EXTEND ASSIGNED ABILITY</strong></div>
|
||||
<label class="field">SECONDS<input type="number" min="0" step="any" value={typeof mutation.seconds === 'number' ? mutation.seconds : 0} oninput={(event) => updateAbilityDurationExtension(clauseIndex,mutationIndex,event.currentTarget.value)}/><small>Applies only while the assigned ability remains active.</small></label>
|
||||
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{mutations:clause.mutations.filter((_,index) => index !== mutationIndex)})}>REMOVE</button>
|
||||
</div>
|
||||
{:else if mutation.kind === 'start_cooldown_on_max'}
|
||||
<div class="mutation-row cooldown-mutation-row">
|
||||
<div class="branch-readout"><span>OPERATION</span><strong>START COOLDOWN ON MAX</strong></div>
|
||||
<SegmentedChoice legend="STACK POOL" name={`${idPrefix}-cooldown-stack-${clauseIndex}-${mutationIndex}`} options={rule.pools.filter((pool) => pool.max && pool.max > 0).map((pool) => ({value:pool.key,label:pool.label.toUpperCase()}))} value={mutation.stackPool} onchange={(values) => updateCooldownOnMax(clauseIndex,mutationIndex,{stackPool:values[0]})}/>
|
||||
<SegmentedChoice legend="COOLDOWN POOL" name={`${idPrefix}-cooldown-pool-${clauseIndex}-${mutationIndex}`} options={rule.pools.filter((pool) => pool.scope === 'agent' && pool.expiresAfterSeconds && pool.key !== mutation.stackPool).map((pool) => ({value:pool.key,label:pool.label.toUpperCase()}))} value={mutation.cooldownPool} onchange={(values) => updateCooldownOnMax(clauseIndex,mutationIndex,{cooldownPool:values[0]})}/>
|
||||
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{mutations:clause.mutations.filter((_,index) => index !== mutationIndex)})}>REMOVE</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mutation-row">
|
||||
<SegmentedChoice
|
||||
legend="STATE OWNER"
|
||||
name={`${idPrefix}-mutation-entity-${clauseIndex}-${mutationIndex}`}
|
||||
options={[{value:'event_target',label:'EVENT TARGET'},{value:'event_actor',label:'EVENT ACTOR'}]}
|
||||
value={'entity' in mutation && mutation.entity?.kind === 'event_actor' ? 'event_actor' : 'event_target'}
|
||||
onchange={(values) => updateMutationEntity(clauseIndex,mutationIndex,values[0] as 'event_target'|'event_actor')}
|
||||
legend="OPERATION"
|
||||
name={`${idPrefix}-mutation-kind-${clauseIndex}-${mutationIndex}`}
|
||||
options={[{value:'add',label:'ADD'},{value:'set',label:'SET'},{value:'consume',label:'CONSUME'},{value:'clear',label:'CLEAR'}]}
|
||||
value={mutation.kind === 'none' || mutation.kind === 'derive' || mutation.kind === 'transfer' ? 'add' : mutation.kind}
|
||||
onchange={(values) => updateMutation(clauseIndex,mutationIndex,values[0] as 'add'|'set'|'consume'|'clear')}
|
||||
/>
|
||||
{/if}
|
||||
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{mutations:clause.mutations.filter((_,index) => index !== mutationIndex)})}>REMOVE</button>
|
||||
</div>
|
||||
<SegmentedChoice
|
||||
legend="STATE POOL"
|
||||
name={`${idPrefix}-mutation-pool-${clauseIndex}-${mutationIndex}`}
|
||||
options={rule.pools.map((pool) => ({value:pool.key,label:pool.label.toUpperCase()}))}
|
||||
value={'pool' in mutation ? mutation.pool : rule.pools[0]?.key ?? ''}
|
||||
columns={Math.min(4,Math.max(1,rule.pools.length))}
|
||||
onchange={(values) => updateMutation(clauseIndex,mutationIndex,mutation.kind === 'clear' ? 'clear' : mutation.kind === 'set' || mutation.kind === 'consume' ? mutation.kind : 'add',values[0])}
|
||||
/>
|
||||
{#if mutation.kind !== 'clear'}
|
||||
<label class="field">VALUE<input type="number" step="any" value={'value' in mutation && typeof mutation.value === 'number' ? mutation.value : 1} oninput={(event) => updateMutation(clauseIndex,mutationIndex,mutation.kind === 'set' || mutation.kind === 'consume' ? mutation.kind : 'add',undefined,Number(event.currentTarget.value))}/></label>
|
||||
{/if}
|
||||
{#if 'pool' in mutation && rule.pools.find((pool) => pool.key === mutation.pool)?.scope === 'target'}
|
||||
<SegmentedChoice
|
||||
legend="STATE OWNER"
|
||||
name={`${idPrefix}-mutation-entity-${clauseIndex}-${mutationIndex}`}
|
||||
options={[{value:'event_target',label:'EVENT TARGET'},{value:'event_actor',label:'EVENT ACTOR'}]}
|
||||
value={'entity' in mutation && mutation.entity?.kind === 'event_actor' ? 'event_actor' : 'event_target'}
|
||||
onchange={(values) => updateMutationEntity(clauseIndex,mutationIndex,values[0] as 'event_target'|'event_actor')}
|
||||
/>
|
||||
{/if}
|
||||
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{mutations:clause.mutations.filter((_,index) => index !== mutationIndex)})}>REMOVE</button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="empty-state">This branch emits outcomes without changing shared state.</p>
|
||||
{/each}
|
||||
@@ -1049,25 +1196,24 @@
|
||||
<div class="outcome-card">
|
||||
<div class="outcome-grid">
|
||||
<label class="field">LABEL<input value={outcome.label} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{label:event.currentTarget.value,key:slug(event.currentTarget.value)})}/></label>
|
||||
<SegmentedChoice legend="KIND" name={`${idPrefix}-kind-${clauseIndex}-${outcomeIndex}`} options={outcomeKinds} value={outcome.kind} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{kind:values[0] as ConditionalMechanicOutcomeDraft['kind'],resource:values[0] === 'restoration' || values[0] === 'resource_cost' ? outcome.resource ?? 'health' : undefined,attributeId:values[0] === 'attribute_modifier' ? outcome.attributeId ?? '' : undefined,delivery:values[0] === 'restoration' ? outcome.delivery : undefined,durationSeconds:values[0] === 'restoration' ? outcome.durationSeconds : undefined})}/>
|
||||
<SegmentedChoice legend="KIND" name={`${idPrefix}-kind-${clauseIndex}-${outcomeIndex}`} options={outcomeKinds} value={outcome.kind} onchange={(values) => setOutcomeKind(clauseIndex,outcomeIndex,values[0] as ConditionalMechanicOutcomeDraft['kind'])}/>
|
||||
<label class="field">DESCRIPTION SLOT<input type="number" min="1" step="1" value={outcome.descriptionSlot ?? ''} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{descriptionSlot:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/></label>
|
||||
</div>
|
||||
<SegmentedChoice legend="TARGET SCOPE" name={`${idPrefix}-scope-${clauseIndex}-${outcomeIndex}`} options={scopeOptions} value={outcome.scope} columns={scopeOptions.length > 4 ? 4 : scopeOptions.length} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{scope:values[0] as MechanicOutcomeScope})}/>
|
||||
<SegmentedChoice legend="TARGET SCOPE" name={`${idPrefix}-scope-${clauseIndex}-${outcomeIndex}`} options={scopeOptions} value={outcome.scope} columns={scopeOptions.length > 4 ? 4 : scopeOptions.length} onchange={(values) => setOutcomeScope(clauseIndex,outcomeIndex,values[0] as MechanicOutcomeScope)}/>
|
||||
{#if outcome.scope === 'allies' || outcome.scope === 'self_and_allies' || outcome.scope === 'target'}
|
||||
<div class="delivery-controls">
|
||||
<SegmentedChoice
|
||||
legend="AREA TARGETING"
|
||||
name={`${idPrefix}-targeting-${clauseIndex}-${outcomeIndex}`}
|
||||
options={[{value:'none',label:'NONE'},{value:'allies',label:'ALLIES IN RADIUS'},{value:'enemies',label:'ENEMIES IN RADIUS'}]}
|
||||
value={outcome.targeting?.population ?? 'none'}
|
||||
onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{
|
||||
targeting: values[0] === 'none'
|
||||
? undefined
|
||||
: {kind:'within_radius',population:values[0] as 'allies'|'enemies',radiusMeters:outcome.targeting?.radiusMeters ?? 20}
|
||||
})}
|
||||
options={targetingOptions(clause,outcome)}
|
||||
value={mechanicTargetingMode(outcome.targeting)}
|
||||
onchange={(values) => setOutcomeTargeting(clauseIndex,outcomeIndex,values[0] as MechanicTargetingMode)}
|
||||
/>
|
||||
{#if outcome.targeting}
|
||||
<label class="field">RADIUS (METERS)<input type="number" min="0.01" step="any" value={outcome.targeting.radiusMeters} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{targeting:{...outcome.targeting!,radiusMeters:Number(event.currentTarget.value)}})}/></label>
|
||||
{#if outcome.targeting.kind === 'event_target_area'}
|
||||
<p class="area-targeting-note"><strong>EVENT TARGET CENTER</strong><span>Damages each valid enemy at or inside the radius once. Runtime events must supply the authoritative target sample.</span></p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1154,8 +1300,9 @@
|
||||
<label class="field">{column.label} VALUE<input type="number" step="any" value={outcome.variants[column.variantKey]?.value ?? 0} oninput={(event) => updateVariant(clauseIndex,outcomeIndex,column.variantKey,event.currentTarget.value)}/></label>
|
||||
{/each}
|
||||
</div>
|
||||
<label class="field probability-field">OUTCOME CHANCE · %<input type="number" min="0" max="100" step="any" value={outcome.probabilityPercent ?? ''} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{probabilityPercent:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/><small>Each eligible outcome rolls independently. Maximum Potential resolves each nonzero chance as successful.</small></label>
|
||||
<div class="outcome-footer">
|
||||
<SegmentedChoice legend="PHASE" name={`${idPrefix}-phase-${clauseIndex}-${outcomeIndex}`} options={[{value:'on_event',label:'ON EVENT'},{value:'after_event',label:'AFTER EVENT'},{value:'while_active',label:'WHILE ACTIVE'},{value:'on_enter',label:'ON ENTER'},{value:'on_exit',label:'ON EXIT'}]} value={outcome.phase} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{phase:values[0] as OutcomePhase})}/>
|
||||
<SegmentedChoice legend="PHASE" name={`${idPrefix}-phase-${clauseIndex}-${outcomeIndex}`} options={phaseOptions(outcome)} value={outcome.phase} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{phase:values[0] as OutcomePhase})}/>
|
||||
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{outcomes:clause.outcomes.filter((_,index) => index !== outcomeIndex)})}>REMOVE OUTCOME</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1198,6 +1345,8 @@
|
||||
.branch>header>div{display:flex;align-items:end;gap:9px}.branch>header>div>span{align-self:center;color:var(--shd-orange);font:900 var(--shd-type-telemetry)/1 var(--shd-font-mono)}.branch>header .field{min-width:min(360px,55vw);margin:0}
|
||||
.branch-grid{display:grid;grid-template-columns:repeat(2,minmax(240px,1fr)) minmax(160px,.45fr);align-items:end;gap:var(--shd-gap-sm);padding:var(--shd-gap-sm)}
|
||||
.condition-controls{display:grid;grid-template-columns:minmax(260px,.7fr) minmax(320px,1.3fr);align-items:end;gap:var(--shd-gap-sm);padding:0 var(--shd-gap-sm) var(--shd-gap-sm)}
|
||||
.ability-proc-controls{display:grid;grid-template-columns:minmax(240px,.7fr) minmax(180px,.4fr);align-items:end;gap:var(--shd-gap-sm)}
|
||||
.ability-proc-controls .field small,.proc-chance-field small,.probability-field small,.ability-mutation-row .field small{display:block;margin-top:4px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.3}
|
||||
.health-boundaries{display:grid;grid-template-columns:repeat(2,minmax(150px,1fr));gap:var(--shd-gap-sm)}
|
||||
.health-boundaries .field small{display:block;margin-top:4px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.3}
|
||||
.predicate-fields{display:grid;grid-template-columns:minmax(170px,1fr) minmax(190px,.8fr);align-items:end;gap:var(--shd-gap-sm)}
|
||||
@@ -1207,12 +1356,15 @@
|
||||
.branch-readout{display:grid;align-content:center;gap:3px;min-height:44px;padding:7px 9px;border:1px solid var(--shd-border);background:var(--shd-surface-raised)}.branch-readout span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.branch-readout strong{font-size:var(--shd-type-supporting)}
|
||||
.reducer-controls{display:grid;grid-template-columns:minmax(0,1fr) repeat(2,minmax(130px,.25fr));align-items:end;gap:var(--shd-gap-sm);padding:0 var(--shd-gap-sm) var(--shd-gap-sm)}
|
||||
.mutation-panel{margin:0 var(--shd-gap-sm) var(--shd-gap-sm);border:1px solid var(--shd-border-soft)}
|
||||
.mutation-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:6px}
|
||||
.mutation-row{display:grid;grid-template-columns:minmax(260px,1fr) minmax(220px,1fr) minmax(110px,.35fr) minmax(240px,1fr) auto;align-items:end;gap:8px;padding:8px;border-bottom:1px solid var(--shd-border-soft)}
|
||||
.ability-mutation-row{grid-template-columns:minmax(240px,1fr) minmax(180px,.5fr) auto}.cooldown-mutation-row{grid-template-columns:minmax(210px,.8fr) minmax(220px,1fr) minmax(220px,1fr) auto}.probability-field{max-width:260px}
|
||||
.outcome-card{display:grid;gap:8px;margin:var(--shd-gap-sm);padding:9px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-info);background:var(--shd-surface-raised)}
|
||||
.outcome-grid{display:grid;grid-template-columns:minmax(200px,1fr) minmax(360px,1.4fr) minmax(120px,.3fr);align-items:end;gap:8px}
|
||||
.variant-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:8px}
|
||||
.state-scaling{display:grid;grid-template-columns:minmax(0,1fr) minmax(260px,.75fr) repeat(2,minmax(110px,.3fr));align-items:end;gap:8px}
|
||||
.delivery-controls{display:grid;grid-template-columns:minmax(260px,1fr) minmax(150px,.35fr);align-items:end;gap:8px}.delivery-controls>.field{margin:0}
|
||||
.area-targeting-note{grid-column:1/-1;display:grid;gap:2px;margin:0;padding:7px 9px;border-left:2px solid var(--shd-info);background:var(--shd-model-surface);color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}.area-targeting-note strong{color:var(--shd-info);font-size:var(--shd-type-telemetry);letter-spacing:.7px}
|
||||
.outcome-footer{display:flex;align-items:end;justify-content:space-between;gap:8px}
|
||||
.remove,.danger-quiet{min-height:38px;padding:0 10px;border:1px solid var(--shd-danger);background:transparent;color:var(--shd-danger);font-size:var(--shd-type-telemetry);font-weight:900;cursor:pointer}
|
||||
.remove:hover,.remove:focus-visible,.danger-quiet:hover,.danger-quiet:focus-visible{background:var(--shd-danger);color:var(--shd-surface)}
|
||||
|
||||
@@ -3,6 +3,11 @@ import type { GameDataTargetType } from '$lib/game-data-targets';
|
||||
|
||||
export type DatabaseBadge = { label: string; tone?: string };
|
||||
export type DatabaseMeter = { label: string; value: number };
|
||||
export type DatabaseWeaponModSlot = {
|
||||
slot: string;
|
||||
unlockedAtTierOne: boolean;
|
||||
iconUrl?: string;
|
||||
};
|
||||
export type DatabaseFact = {
|
||||
label: string;
|
||||
value: string;
|
||||
@@ -26,8 +31,10 @@ export type DatabaseItem = {
|
||||
renderUrl?: string;
|
||||
gameIcon?: GameIconName;
|
||||
headerBadge?: string;
|
||||
headerBadgeTone?: string;
|
||||
headerBadges?: string[];
|
||||
headerMetaIcon?: GameIconName;
|
||||
topBorderTone?: string;
|
||||
badges?: DatabaseBadge[];
|
||||
facts: DatabaseFact[];
|
||||
factLayout?: 'grid' | 'stack';
|
||||
@@ -35,9 +42,17 @@ export type DatabaseItem = {
|
||||
lines?: DatabaseFact[];
|
||||
afterLines?: DatabaseFact[];
|
||||
iconGroups?: DatabaseIconGroup[];
|
||||
weaponModSlots?: DatabaseWeaponModSlot[];
|
||||
description?: string;
|
||||
secondaryDescription?: string;
|
||||
descriptionStyle?: 'prose' | 'data';
|
||||
protocolPreview?: {
|
||||
primaryStat: string;
|
||||
primaryUnit: string;
|
||||
primaryBaseValue: number;
|
||||
talentDescription?: string;
|
||||
talentEffects: Array<{ baseValue: number; unit: string }>;
|
||||
};
|
||||
};
|
||||
|
||||
export type DatabaseRelatedItem = {
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
MOD_SECONDARY_CAPACITY,
|
||||
normalizeModSecondaryPool,
|
||||
} from './secondary-pool';
|
||||
|
||||
const endpoints = (
|
||||
level1Min: number | string,
|
||||
level1Max: number | string,
|
||||
level20Min: number | string,
|
||||
level20Max: number | string,
|
||||
) => ({ level1Min, level1Max, level20Min, level20Max });
|
||||
|
||||
const range = (statId: string) => ({
|
||||
statId,
|
||||
values: {
|
||||
Specialized: endpoints('1.5', '2.5', '3.5', '4.5'),
|
||||
Superior: endpoints(2, 3, 4, 5),
|
||||
'High-end': endpoints(3, 4, 5, 6),
|
||||
},
|
||||
});
|
||||
|
||||
describe('shared Mod secondary pool', () => {
|
||||
it('defines secondary capacity for every Mod quality', () => {
|
||||
expect(MOD_SECONDARY_CAPACITY).toEqual({
|
||||
Standard: 0,
|
||||
Specialized: 1,
|
||||
Superior: 2,
|
||||
'High-end': 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes Attribute IDs and valid Level 1/20 endpoints', () => {
|
||||
const result = normalizeModSecondaryPool([
|
||||
range(' critical-hit-chance '),
|
||||
range('critical-hit-damage'),
|
||||
]);
|
||||
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.pool).toEqual([
|
||||
{
|
||||
statId: 'critical-hit-chance',
|
||||
values: {
|
||||
Specialized: endpoints(1.5, 2.5, 3.5, 4.5),
|
||||
Superior: endpoints(2, 3, 4, 5),
|
||||
'High-end': endpoints(3, 4, 5, 6),
|
||||
},
|
||||
},
|
||||
{
|
||||
statId: 'critical-hit-damage',
|
||||
values: {
|
||||
Specialized: endpoints(1.5, 2.5, 3.5, 4.5),
|
||||
Superior: endpoints(2, 3, 4, 5),
|
||||
'High-end': endpoints(3, 4, 5, 6),
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects duplicate Attributes after ID normalization', () => {
|
||||
const result = normalizeModSecondaryPool([
|
||||
range('critical-hit-chance'),
|
||||
range(' critical-hit-chance '),
|
||||
]);
|
||||
|
||||
expect(result).toEqual({
|
||||
pool: [],
|
||||
error: 'Choose a unique Attribute for every shared pool option.',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'missing quality ranges',
|
||||
{
|
||||
...range('critical-hit-chance'),
|
||||
values: {
|
||||
Specialized: endpoints(1, 2, 3, 4),
|
||||
Superior: endpoints(2, 3, 4, 5),
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
'reversed ranges',
|
||||
{
|
||||
...range('critical-hit-chance'),
|
||||
values: {
|
||||
...range('critical-hit-chance').values,
|
||||
Specialized: endpoints(2, 1, 3, 4),
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
'Level 20 regression',
|
||||
{
|
||||
...range('critical-hit-chance'),
|
||||
values: {
|
||||
...range('critical-hit-chance').values,
|
||||
Specialized: endpoints(2, 3, 1, 2),
|
||||
},
|
||||
},
|
||||
],
|
||||
])('rejects %s', (_label, invalidRange) => {
|
||||
const result = normalizeModSecondaryPool([
|
||||
invalidRange,
|
||||
range('critical-hit-damage'),
|
||||
]);
|
||||
|
||||
expect(result).toEqual({
|
||||
pool: [],
|
||||
error:
|
||||
'Configure valid Level 1 and Level 20 ranges for Specialized, Superior, and High-end quality.',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
export const MOD_QUALITIES = [
|
||||
'Standard',
|
||||
'Specialized',
|
||||
'Superior',
|
||||
'High-end',
|
||||
] as const;
|
||||
|
||||
export const MOD_SECONDARY_QUALITIES = [
|
||||
'Specialized',
|
||||
'Superior',
|
||||
'High-end',
|
||||
] as const;
|
||||
|
||||
export const MOD_SECONDARY_CAPACITY = {
|
||||
Standard: 0,
|
||||
Specialized: 1,
|
||||
Superior: 2,
|
||||
'High-end': 2,
|
||||
} as const satisfies Record<(typeof MOD_QUALITIES)[number], number>;
|
||||
|
||||
export type ModSecondaryQuality = (typeof MOD_SECONDARY_QUALITIES)[number];
|
||||
|
||||
export type ModSecondaryRange = {
|
||||
statId: string;
|
||||
values: Record<
|
||||
ModSecondaryQuality,
|
||||
{
|
||||
level1Min: number;
|
||||
level1Max: number;
|
||||
level20Min: number;
|
||||
level20Max: number;
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
export function normalizeModSecondaryPool(input: unknown): {
|
||||
pool: ModSecondaryRange[];
|
||||
error: string | null;
|
||||
} {
|
||||
if (!Array.isArray(input) || input.length < 2 || input.length > 50) {
|
||||
return {
|
||||
pool: [],
|
||||
error: 'Configure between two and 50 shared secondary Attributes.',
|
||||
};
|
||||
}
|
||||
|
||||
const pool = input.map((candidate) => {
|
||||
const range = candidate as Partial<ModSecondaryRange>;
|
||||
return {
|
||||
statId: typeof range.statId === 'string' ? range.statId.trim() : '',
|
||||
values: Object.fromEntries(
|
||||
MOD_SECONDARY_QUALITIES.map((quality) => {
|
||||
const values = range.values?.[quality] as
|
||||
| Partial<ModSecondaryRange['values'][ModSecondaryQuality]>
|
||||
| undefined;
|
||||
return [
|
||||
quality,
|
||||
{
|
||||
level1Min: Number(values?.level1Min),
|
||||
level1Max: Number(values?.level1Max),
|
||||
level20Min: Number(values?.level20Min),
|
||||
level20Max: Number(values?.level20Max),
|
||||
},
|
||||
];
|
||||
}),
|
||||
) as ModSecondaryRange['values'],
|
||||
};
|
||||
});
|
||||
|
||||
if (
|
||||
pool.some((range) => !range.statId) ||
|
||||
new Set(pool.map((range) => range.statId)).size !== pool.length
|
||||
) {
|
||||
return {
|
||||
pool: [],
|
||||
error: 'Choose a unique Attribute for every shared pool option.',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
pool.some((range) =>
|
||||
MOD_SECONDARY_QUALITIES.some((quality) => {
|
||||
const values = range.values[quality];
|
||||
return (
|
||||
Object.values(values).some((value) => !Number.isFinite(value)) ||
|
||||
values.level1Max < values.level1Min ||
|
||||
values.level20Max < values.level20Min ||
|
||||
values.level20Min < values.level1Min ||
|
||||
values.level20Max < values.level1Max
|
||||
);
|
||||
}),
|
||||
)
|
||||
) {
|
||||
return {
|
||||
pool: [],
|
||||
error:
|
||||
'Configure valid Level 1 and Level 20 ranges for Specialized, Superior, and High-end quality.',
|
||||
};
|
||||
}
|
||||
|
||||
return { pool, error: null };
|
||||
}
|
||||
@@ -94,7 +94,7 @@ describe('AI mechanic authoring prompt', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(AI_MECHANIC_PROMPT_VERSION).toBe(7);
|
||||
expect(AI_MECHANIC_PROMPT_VERSION).toBe(10);
|
||||
expect(input.canonicalCatalog.grammar.outcome.optional).toEqual(
|
||||
expect.arrayContaining(['delivery', 'durationSeconds']),
|
||||
);
|
||||
@@ -136,7 +136,7 @@ describe('AI mechanic authoring prompt', () => {
|
||||
]),
|
||||
);
|
||||
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
|
||||
'Prompt contract version: 7',
|
||||
'Prompt contract version: 10',
|
||||
);
|
||||
|
||||
const advertised = structuredClone(
|
||||
@@ -185,7 +185,7 @@ describe('AI mechanic authoring prompt', () => {
|
||||
expect(parsed.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('advertises the canonical Extra Health change event contract in version 7', () => {
|
||||
it('advertises the canonical Extra Health change event contract in version 10', () => {
|
||||
const input = JSON.parse(
|
||||
buildAiMechanicAuthoringInput({
|
||||
request,
|
||||
@@ -196,9 +196,9 @@ describe('AI mechanic authoring prompt', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(AI_MECHANIC_PROMPT_VERSION).toBe(7);
|
||||
expect(AI_MECHANIC_PROMPT_VERSION).toBe(10);
|
||||
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
|
||||
'Prompt contract version: 7',
|
||||
'Prompt contract version: 10',
|
||||
);
|
||||
expect(input.canonicalCatalog.events).toContainEqual({
|
||||
key: 'extra_health_changed',
|
||||
@@ -222,6 +222,144 @@ describe('AI mechanic authoring prompt', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('advertises assigned-ability activity for kill and assist predicates', () => {
|
||||
const input = JSON.parse(
|
||||
buildAiMechanicAuthoringInput({
|
||||
request,
|
||||
attributes: [],
|
||||
events: [
|
||||
MECHANIC_EVENT_REGISTRY.enemy_killed,
|
||||
MECHANIC_EVENT_REGISTRY.assist,
|
||||
],
|
||||
actions: [],
|
||||
recipeKeys: [],
|
||||
}),
|
||||
);
|
||||
|
||||
for (const event of input.canonicalCatalog.events) {
|
||||
expect(event.fields).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
path: 'values.assignedAbilityActive',
|
||||
label: 'Assigned ability active',
|
||||
type: 'boolean',
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
expect(input.examplesAndRules).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('values.assignedAbilityActive'),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('advertises and round-trips aimed event-target area damage', () => {
|
||||
const protocolRequest: AiMechanicAuthoringRequest = {
|
||||
...request,
|
||||
context: {
|
||||
sourceKind: 'os_protocol_talent',
|
||||
sourceName: 'Untitled mechanic',
|
||||
sourceDescription:
|
||||
'Critical Hits performed during aiming create an explosion with a radius of 5 meters around the target that deals damage equal to 2000% Firepower to them.',
|
||||
},
|
||||
valueColumns: [{ key: 'base', variantKey: 'base', label: 'BASE' }],
|
||||
allowProtocolUpgradeScaling: true,
|
||||
};
|
||||
const input = JSON.parse(
|
||||
buildAiMechanicAuthoringInput({
|
||||
request: protocolRequest,
|
||||
attributes: [
|
||||
{
|
||||
id: 'firepower-id',
|
||||
name: 'Firepower',
|
||||
description: null,
|
||||
unit: '',
|
||||
type: null,
|
||||
category: null,
|
||||
calculationType: null,
|
||||
},
|
||||
],
|
||||
events: [MECHANIC_EVENT_REGISTRY.weapon_hit],
|
||||
actions: [],
|
||||
recipeKeys: [],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(input.canonicalCatalog.events[0].fields).toContainEqual({
|
||||
path: 'values.aiming',
|
||||
label: 'Aiming down sights',
|
||||
type: 'boolean',
|
||||
});
|
||||
expect(
|
||||
input.canonicalCatalog.grammar.outcome.targeting.eventTargetArea,
|
||||
).toMatchObject({
|
||||
kind: 'event_target_area',
|
||||
center: 'event_target',
|
||||
validTargets: 'enemies',
|
||||
});
|
||||
expect(input.examplesAndRules).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('values.aiming'),
|
||||
expect.stringContaining('event_target_area'),
|
||||
]),
|
||||
);
|
||||
|
||||
const advertised = structuredClone(
|
||||
input.canonicalCatalog.grammar.aimedCriticalAreaDamageShape,
|
||||
);
|
||||
const outcome = advertised.clauses[0].outcomes[0];
|
||||
const base = JSON.parse(
|
||||
JSON.stringify(
|
||||
outcome.variants['replace-with-each-required-variant-key'],
|
||||
).replaceAll(
|
||||
'replace-with-an-exact-catalog-attribute-id',
|
||||
'firepower-id',
|
||||
),
|
||||
);
|
||||
outcome.variants = { base };
|
||||
const parsed = parseConditionalMechanicDraft(advertised, {
|
||||
variantKeys: ['base'],
|
||||
allowedAttributeIds: new Set(['firepower-id']),
|
||||
});
|
||||
expect(parsed.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('advertises reproducible outcome and clause proc probabilities in version 10', () => {
|
||||
const input = JSON.parse(
|
||||
buildAiMechanicAuthoringInput({
|
||||
request,
|
||||
attributes: [],
|
||||
events: [MECHANIC_EVENT_REGISTRY.skill_hit],
|
||||
actions: [],
|
||||
recipeKeys: [],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(AI_MECHANIC_PROMPT_VERSION).toBe(10);
|
||||
expect(input.canonicalCatalog.grammar.clause.optional).toEqual(
|
||||
expect.arrayContaining([
|
||||
'procChancePercent',
|
||||
'requiresAssignedAbilityActive',
|
||||
]),
|
||||
);
|
||||
expect(input.canonicalCatalog.grammar.mutationKinds).toEqual(
|
||||
expect.arrayContaining(['extend_duration', 'start_cooldown_on_max']),
|
||||
);
|
||||
expect(
|
||||
input.canonicalCatalog.grammar.probabilityOutcomeShape,
|
||||
).toMatchObject({
|
||||
probabilityPercent: 29,
|
||||
resource: 'ammo',
|
||||
});
|
||||
expect(input.examplesAndRules).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('procChancePercent'),
|
||||
expect.stringContaining('reproducible seeded random stream'),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('advertises authoritative current Health percentage with strict band semantics', () => {
|
||||
const input = JSON.parse(
|
||||
buildAiMechanicAuthoringInput({
|
||||
|
||||
@@ -3,7 +3,7 @@ import { CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES } from '$lib/calculations/mec
|
||||
import { MECHANIC_COMBAT_REFERENCE_REGISTRY } from '$lib/calculations/mechanic';
|
||||
import type { AiMechanicAttribute } from './mechanic-authoring-context';
|
||||
|
||||
export const AI_MECHANIC_PROMPT_VERSION = 7;
|
||||
export const AI_MECHANIC_PROMPT_VERSION = 10;
|
||||
|
||||
const mechanicsGrammar = {
|
||||
version: 1,
|
||||
@@ -40,7 +40,14 @@ const mechanicsGrammar = {
|
||||
},
|
||||
clause: {
|
||||
required: ['key', 'event', 'if', 'reducer', 'mutations', 'outcomes'],
|
||||
optional: ['label', 'recipeKey', 'cooldownSeconds', 'weight'],
|
||||
optional: [
|
||||
'label',
|
||||
'recipeKey',
|
||||
'cooldownSeconds',
|
||||
'weight',
|
||||
'procChancePercent',
|
||||
'requiresAssignedAbilityActive',
|
||||
],
|
||||
},
|
||||
predicateKinds: ['true', 'exists', 'compare', 'all', 'any', 'not'],
|
||||
compareOperators: ['eq', 'ne', 'gt', 'gte', 'lt', 'lte'],
|
||||
@@ -63,6 +70,8 @@ const mechanicsGrammar = {
|
||||
'derive',
|
||||
'clear',
|
||||
'transfer',
|
||||
'extend_duration',
|
||||
'start_cooldown_on_max',
|
||||
],
|
||||
entitySelectors: ['agent', 'event_actor', 'event_target', 'event_field'],
|
||||
valueReferences: [
|
||||
@@ -70,6 +79,7 @@ const mechanicsGrammar = {
|
||||
'scenario',
|
||||
'combat',
|
||||
'equipment',
|
||||
'assigned_ability',
|
||||
'attribute',
|
||||
'reducer',
|
||||
'pool',
|
||||
@@ -97,6 +107,7 @@ const mechanicsGrammar = {
|
||||
},
|
||||
},
|
||||
{ ref: { source: 'equipment', path: 'protocolTalentUpgrades' } },
|
||||
{ ref: { source: 'assigned_ability', path: 'remainingDurationSeconds' } },
|
||||
{
|
||||
ref: {
|
||||
source: 'attribute',
|
||||
@@ -200,6 +211,16 @@ const mechanicsGrammar = {
|
||||
value: 'all',
|
||||
clearSource: true,
|
||||
},
|
||||
{
|
||||
kind: 'extend_duration',
|
||||
target: 'assigned_ability',
|
||||
seconds: 0.3,
|
||||
},
|
||||
{
|
||||
kind: 'start_cooldown_on_max',
|
||||
stackPool: 'extension-stacks',
|
||||
cooldownPool: 'extension-cooldown',
|
||||
},
|
||||
],
|
||||
outcome: CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES,
|
||||
continuousRestorationOutcomeShape: {
|
||||
@@ -232,6 +253,83 @@ const mechanicsGrammar = {
|
||||
},
|
||||
},
|
||||
},
|
||||
probabilityOutcomeShape: {
|
||||
key: 'recover-ammo',
|
||||
label: 'Recover Ammo',
|
||||
phase: 'after_event',
|
||||
kind: 'restoration',
|
||||
scope: 'self',
|
||||
basis: 'flat',
|
||||
resource: 'ammo',
|
||||
probabilityPercent: 29,
|
||||
variants: {
|
||||
'replace-with-each-required-variant-key': { value: 1 },
|
||||
},
|
||||
},
|
||||
aimedCriticalAreaDamageShape: {
|
||||
version: 1,
|
||||
simulationReadiness: 'ready',
|
||||
scenarioInputs: [],
|
||||
pools: [],
|
||||
clauses: [
|
||||
{
|
||||
key: 'aimed-critical-explosion',
|
||||
label: 'Aimed critical hit explosion',
|
||||
event: 'weapon_hit',
|
||||
if: {
|
||||
kind: 'all',
|
||||
predicates: [
|
||||
{ kind: 'exists', value: { source: 'event', path: 'targetId' } },
|
||||
{
|
||||
kind: 'compare',
|
||||
left: { source: 'event', path: 'values.critical' },
|
||||
operator: 'eq',
|
||||
right: true,
|
||||
},
|
||||
{
|
||||
kind: 'compare',
|
||||
left: { source: 'event', path: 'values.aiming' },
|
||||
operator: 'eq',
|
||||
right: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [],
|
||||
outcomes: [
|
||||
{
|
||||
key: 'critical-explosion-damage',
|
||||
label: 'Critical explosion damage',
|
||||
phase: 'after_event',
|
||||
kind: 'direct_damage',
|
||||
scope: 'target',
|
||||
basis: 'flat',
|
||||
targeting: {
|
||||
kind: 'event_target_area',
|
||||
center: 'event_target',
|
||||
validTargets: 'enemies',
|
||||
radiusMeters: 5,
|
||||
},
|
||||
variants: {
|
||||
'replace-with-each-required-variant-key': {
|
||||
value: 2000,
|
||||
expression: {
|
||||
op: 'multiply',
|
||||
left: 20,
|
||||
right: {
|
||||
ref: {
|
||||
source: 'attribute',
|
||||
attributeId: 'replace-with-an-exact-catalog-attribute-id',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
preciseTraumaShape: {
|
||||
version: 1,
|
||||
simulationReadiness: 'ready',
|
||||
@@ -453,9 +551,16 @@ export function buildAiMechanicAuthoringInput(input: {
|
||||
'A target-scoped pool owns an independent value and expiry timer for each entity. Bind hit-created target state with entity:{kind:"event_target"}; do not collapse different enemies into one Agent-scoped pool.',
|
||||
'Writing a target-scoped pool refreshes only that target entity timer. An on_exit outcome runs when that entity expires and uses the entity previous state for stack bands and perStack multiplication, even though the stored pool value has already reset.',
|
||||
'When an outcome value is exactly a live Attribute amount, use the Attribute reference directly as the variant expression. Divide by 100 only when a displayed percentage is being used as a multiplier.',
|
||||
'A weapon_hit may compare values.aiming with true only when the wording requires aiming down sights at hit resolution. Missing aiming evidence does not match; do not replace it with a scenario assumption.',
|
||||
'An explosion centered on the hit target uses target-scoped direct_damage with targeting kind event_target_area, center event_target, validTargets enemies, and the stated positive radiusMeters. The runtime applies one equal damage emission to each authoritative valid enemy at or inside the radius. Do not use legacy within_radius metadata for explosion damage.',
|
||||
'Precise Trauma is representable as one target-scoped stack pool with a one-second expiry: body shots add one stack, headshots add two, and one on_exit direct-damage outcome uses the expiring target previous stack count. This models the stated one-second debuff without inventing generic repeated target ticks.',
|
||||
'OS Protocol upgrade scaling may use equipment.protocolTalentUpgrades only when allowProtocolUpgradeScaling is true.',
|
||||
'Assigned-ability scope may be used only when allowAssignedAbility is true.',
|
||||
'When allowAssignedAbility is true, a kill or assist that must occur while the source item\'s assigned ability is active uses enemy_killed or assist with {source:"event",path:"values.assignedAbilityActive"} compared to true. This field is optional on the event; a missing value does not match.',
|
||||
'When allowAssignedAbility is true, requiresAssignedAbilityActive gates a clause on the runtime assigned-ability state. skill_deployed and signature_ability_deployed may supply values.assignedAbilityDurationSeconds to begin that state. At an exact expiry timestamp, the ability expires before a later clause can extend it.',
|
||||
'procChancePercent is a clause-level independent percentage roll after its predicate and cooldown checks but before the reducer, mutations, and outcomes. Use it when a successful proc changes state. probabilityPercent is an outcome-level independent percentage roll for one outcome only.',
|
||||
'Custom simulation uses a reproducible seeded random stream. Maximum Potential resolves every nonzero chance as successful. Never use clause weight as a proc chance.',
|
||||
'extend_duration may target only assigned_ability and does nothing while the ability is inactive. start_cooldown_on_max starts a configured expiring Agent cooldown pool only when a mutation in the same clause transitions its capped stack pool to its maximum.',
|
||||
'If cadence is not stated, ask rather than assuming a one-second interval.',
|
||||
'If wording contains separate actions such as restoration on a periodic snapshot and a health cost on every shot, represent them as separate clauses.',
|
||||
'A pool with expiresAfterSeconds refreshes the shared expiry for every current stack whenever any mutation writes that pool. Use it for mechanics where gaining a stack refreshes the duration of the full stack pool.',
|
||||
|
||||
@@ -9,21 +9,35 @@ describe('AI mechanic authoring rate limit', () => {
|
||||
beforeEach(() => resetAiMechanicRateLimitForTests());
|
||||
|
||||
it('allows only one in-flight request per account', () => {
|
||||
const release = reserveAiMechanicGeneration('agent-1', 1_000);
|
||||
expect(() => reserveAiMechanicGeneration('agent-1', 1_001)).toThrow(
|
||||
const release = reserveAiMechanicGeneration('agent-1', false, 1_000);
|
||||
expect(() => reserveAiMechanicGeneration('agent-1', false, 1_001)).toThrow(
|
||||
AiMechanicRateLimitError,
|
||||
);
|
||||
release();
|
||||
expect(() => reserveAiMechanicGeneration('agent-1', 1_002)).not.toThrow();
|
||||
expect(() =>
|
||||
reserveAiMechanicGeneration('agent-1', false, 1_002),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('enforces the hourly account limit', () => {
|
||||
for (let request = 0; request < 10; request += 1) {
|
||||
const release = reserveAiMechanicGeneration('agent-1', 10_000 + request);
|
||||
const release = reserveAiMechanicGeneration(
|
||||
'agent-1',
|
||||
false,
|
||||
10_000 + request,
|
||||
);
|
||||
release();
|
||||
}
|
||||
expect(() => reserveAiMechanicGeneration('agent-1', 10_100)).toThrow(
|
||||
expect(() => reserveAiMechanicGeneration('agent-1', false, 10_100)).toThrow(
|
||||
'This account has reached the hourly mechanic authoring limit.',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not limit administrators', () => {
|
||||
const releases = Array.from({ length: 101 }, () =>
|
||||
reserveAiMechanicGeneration('admin-1', true, 10_000),
|
||||
);
|
||||
|
||||
for (const release of releases) release();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,7 +27,12 @@ const retryAfter = (requests: number[], now: number) =>
|
||||
* duplicate submissions and basic abuse; OpenAI project budgets remain the hard
|
||||
* cross-instance spending boundary.
|
||||
*/
|
||||
export function reserveAiMechanicGeneration(userId: string, now = Date.now()) {
|
||||
export function reserveAiMechanicGeneration(
|
||||
userId: string,
|
||||
isAdmin = false,
|
||||
now = Date.now(),
|
||||
) {
|
||||
if (isAdmin) return () => {};
|
||||
if (inFlightUsers.has(userId)) {
|
||||
throw new AiMechanicRateLimitError(
|
||||
'A mechanic authoring request is already running for this account.',
|
||||
|
||||
@@ -77,7 +77,7 @@ export async function syncArticleTags(database: Database) {
|
||||
`specialization-${item.id}`,
|
||||
'specialization',
|
||||
String(item.name),
|
||||
`specialization-${slugify(String(item.name))}`,
|
||||
`specialization-${slugify(String(item.name))}-${item.id}`,
|
||||
'specialization',
|
||||
item.id,
|
||||
Number(item.active) ? 1 : 0,
|
||||
@@ -93,7 +93,7 @@ export async function syncArticleTags(database: Database) {
|
||||
`focus-${item.id}`,
|
||||
'focus',
|
||||
name,
|
||||
`focus-${slugify(name)}`,
|
||||
`focus-${slugify(name)}-${item.id}`,
|
||||
'focus',
|
||||
item.id,
|
||||
Number(item.active) ? 1 : 0,
|
||||
|
||||
@@ -34,6 +34,7 @@ export async function recordIsacAudit(input: {
|
||||
export async function auditRecord(
|
||||
table: string,
|
||||
id: string,
|
||||
suppliedDatabase?: Database,
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
if (
|
||||
![
|
||||
@@ -56,7 +57,7 @@ export async function auditRecord(
|
||||
) {
|
||||
throw new Error('Unsupported audit table');
|
||||
}
|
||||
const database = await getDatabase();
|
||||
const database = suppliedDatabase ?? (await getDatabase());
|
||||
return (
|
||||
(await (
|
||||
await database.prepare(`SELECT * FROM ${table} WHERE id=?`)
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { connect, type Database } from '@tursodatabase/database';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
audit: vi.fn(async () => undefined),
|
||||
unlink: vi.fn(async () => undefined),
|
||||
avatarDirectory: vi.fn(() => '/tmp/account-deletion-avatars'),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/audit', () => ({ recordIsacAudit: mocks.audit }));
|
||||
vi.mock('node:fs/promises', () => ({ unlink: mocks.unlink }));
|
||||
vi.mock('$lib/server/avatars', () => ({
|
||||
avatarStorageDirectory: mocks.avatarDirectory,
|
||||
}));
|
||||
vi.mock('$lib/agents', () => ({
|
||||
displayNameKey: (value: string) => value.toLowerCase(),
|
||||
}));
|
||||
|
||||
import {
|
||||
LastAdministratorDeletionError,
|
||||
softDeleteAccount,
|
||||
} from './account-deletion';
|
||||
|
||||
async function createDatabase(): Promise<Database> {
|
||||
const database = await connect(':memory:');
|
||||
await database.exec(`
|
||||
CREATE TABLE users(
|
||||
id TEXT PRIMARY KEY, username TEXT NOT NULL, login_username TEXT,
|
||||
password_hash TEXT, discord_id TEXT, role TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO users VALUES('user-1','agent','agent','hash','discord','user');
|
||||
CREATE TABLE user_profiles(
|
||||
user_id TEXT PRIMARY KEY, display_name TEXT, display_name_key TEXT,
|
||||
bio_markdown TEXT NOT NULL DEFAULT '', is_public INTEGER NOT NULL DEFAULT 1,
|
||||
bio_updated_at TEXT, updated_at TEXT
|
||||
);
|
||||
INSERT INTO user_profiles(user_id,display_name) VALUES('user-1','Agent');
|
||||
CREATE TABLE user_avatars(user_id TEXT PRIMARY KEY, source TEXT, value TEXT);
|
||||
CREATE TABLE avatar_reviews(
|
||||
id TEXT PRIMARY KEY, user_id TEXT, status TEXT, reviewed_at TEXT
|
||||
);
|
||||
INSERT INTO user_avatars(user_id,source,value)
|
||||
VALUES('user-1','upload','11111111-1111-4111-8111-111111111111.webp');
|
||||
CREATE TABLE user_status(user_id TEXT PRIMARY KEY, blocked_at TEXT, banned_until TEXT);
|
||||
CREATE TABLE deleted_accounts(
|
||||
user_id TEXT PRIMARY KEY, original_username TEXT, deleted_at TEXT,
|
||||
deleted_by TEXT, reason TEXT
|
||||
);
|
||||
CREATE TABLE sessions(id TEXT PRIMARY KEY, user_id TEXT);
|
||||
CREATE TABLE magic_link_tokens(id TEXT PRIMARY KEY, user_id TEXT);
|
||||
CREATE TABLE webauthn_challenges(id TEXT PRIMARY KEY, user_id TEXT);
|
||||
CREATE TABLE passkeys(id TEXT PRIMARY KEY, user_id TEXT);
|
||||
CREATE TABLE user_emails(id TEXT PRIMARY KEY, user_id TEXT);
|
||||
CREATE TABLE oauth_states(id TEXT PRIMARY KEY, user_id TEXT);
|
||||
CREATE TABLE discord_profiles(user_id TEXT PRIMARY KEY);
|
||||
CREATE TABLE user_roles(user_id TEXT, role_id TEXT);
|
||||
CREATE TABLE user_permissions(user_id TEXT, permission_id TEXT);
|
||||
CREATE TABLE login_events(id TEXT PRIMARY KEY, user_id TEXT);
|
||||
CREATE TABLE mfa_totp_factors(
|
||||
id TEXT PRIMARY KEY, user_id TEXT, state TEXT, encrypted_secret TEXT,
|
||||
encryption_key_id TEXT, revoked_at TEXT
|
||||
);
|
||||
CREATE TABLE mfa_recovery_codes(
|
||||
id TEXT PRIMARY KEY, user_id TEXT, code_hash TEXT,
|
||||
consumed_at TEXT, revoked_at TEXT
|
||||
);
|
||||
CREATE TABLE authentication_transactions(
|
||||
id TEXT PRIMARY KEY, user_id TEXT, consumed_at TEXT
|
||||
);
|
||||
INSERT INTO mfa_totp_factors VALUES('factor-1','user-1','pending','mfa1.old','old-key',NULL);
|
||||
INSERT INTO mfa_recovery_codes VALUES('recovery-1','user-1','hash',NULL,NULL);
|
||||
INSERT INTO authentication_transactions VALUES('tx-1','user-1',NULL);
|
||||
`);
|
||||
return database;
|
||||
}
|
||||
|
||||
describe('account deletion MFA erasure', () => {
|
||||
let database: Database;
|
||||
|
||||
beforeEach(async () => {
|
||||
database = await createDatabase();
|
||||
mocks.audit.mockReset();
|
||||
mocks.audit.mockResolvedValue(undefined);
|
||||
mocks.unlink.mockReset();
|
||||
mocks.unlink.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(async () => database.close());
|
||||
|
||||
it('purges pending MFA transactions, recovery codes, and factor material', async () => {
|
||||
expect(
|
||||
await softDeleteAccount(database, 'user-1', 'admin-1', 'request', true),
|
||||
).toBe(true);
|
||||
expect(mocks.unlink).toHaveBeenCalledWith(
|
||||
'/tmp/account-deletion-avatars/11111111-1111-4111-8111-111111111111.webp',
|
||||
);
|
||||
for (const table of [
|
||||
'mfa_totp_factors',
|
||||
'mfa_recovery_codes',
|
||||
'authentication_transactions',
|
||||
]) {
|
||||
const row = await (
|
||||
await database.prepare(
|
||||
`SELECT COUNT(*) AS count FROM ${table} WHERE user_id=?`,
|
||||
)
|
||||
).get('user-1');
|
||||
expect(Number(row?.count)).toBe(0);
|
||||
}
|
||||
const user = await (
|
||||
await database.prepare(
|
||||
'SELECT username,password_hash,discord_id FROM users WHERE id=?',
|
||||
)
|
||||
).get('user-1');
|
||||
expect(user).toMatchObject({
|
||||
username: 'deleted-agent-user-1',
|
||||
password_hash: null,
|
||||
discord_id: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('rolls back all deletion and MFA erasure when audit recording fails', async () => {
|
||||
mocks.audit.mockRejectedValueOnce(new Error('audit unavailable'));
|
||||
await expect(
|
||||
softDeleteAccount(database, 'user-1', 'admin-1', 'request', true),
|
||||
).rejects.toThrow('audit unavailable');
|
||||
expect(mocks.unlink).not.toHaveBeenCalled();
|
||||
const user = await (
|
||||
await database.prepare(
|
||||
'SELECT username,password_hash FROM users WHERE id=?',
|
||||
)
|
||||
).get('user-1');
|
||||
expect(user).toMatchObject({ username: 'agent', password_hash: 'hash' });
|
||||
const factor = await (
|
||||
await database.prepare(
|
||||
'SELECT encrypted_secret,state FROM mfa_totp_factors WHERE id=?',
|
||||
)
|
||||
).get('factor-1');
|
||||
expect(factor).toMatchObject({
|
||||
encrypted_secret: 'mfa1.old',
|
||||
state: 'pending',
|
||||
});
|
||||
const deleted = await (
|
||||
await database.prepare('SELECT 1 FROM deleted_accounts WHERE user_id=?')
|
||||
).get('user-1');
|
||||
expect(deleted).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not clean the avatar when a transaction mutation fails', async () => {
|
||||
await database.exec('DROP TABLE user_permissions');
|
||||
await expect(
|
||||
softDeleteAccount(database, 'user-1', 'admin-1', 'request', true),
|
||||
).rejects.toThrow();
|
||||
expect(mocks.unlink).not.toHaveBeenCalled();
|
||||
const user = await (
|
||||
await database.prepare('SELECT username FROM users WHERE id=?')
|
||||
).get('user-1');
|
||||
expect(user?.username).toBe('agent');
|
||||
});
|
||||
|
||||
it('preserves committed deletion when post-commit avatar cleanup fails', async () => {
|
||||
mocks.unlink.mockRejectedValueOnce(new Error('unlink failed'));
|
||||
const log = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
expect(
|
||||
await softDeleteAccount(database, 'user-1', 'admin-1', 'request'),
|
||||
).toBe(true);
|
||||
expect(log).toHaveBeenCalledWith(
|
||||
'[account-deletion] committed deletion; uploaded avatar cleanup failed.',
|
||||
{ userId: 'user-1' },
|
||||
);
|
||||
const user = await (
|
||||
await database.prepare('SELECT username FROM users WHERE id=?')
|
||||
).get('user-1');
|
||||
expect(user?.username).toBe('deleted-agent-user-1');
|
||||
log.mockRestore();
|
||||
});
|
||||
|
||||
it('rejects deletion of the sole active canonical administrator', async () => {
|
||||
await database.exec(
|
||||
"INSERT INTO user_roles(user_id,role_id) VALUES('user-1','role-admin')",
|
||||
);
|
||||
|
||||
await expect(
|
||||
softDeleteAccount(database, 'user-1', 'user-1', 'request'),
|
||||
).rejects.toBeInstanceOf(LastAdministratorDeletionError);
|
||||
|
||||
const deleted = await (
|
||||
await database.prepare('SELECT 1 FROM deleted_accounts WHERE user_id=?')
|
||||
).get('user-1');
|
||||
expect(deleted).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,24 @@
|
||||
import type { Database } from '@tursodatabase/database';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { deleteUploadedAvatar } from '$lib/server/avatars';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { unlink } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { avatarStorageDirectory } from '$lib/server/avatars';
|
||||
import { displayNameKey } from '$lib/agents';
|
||||
import { recordIsacAudit } from '$lib/server/audit';
|
||||
import {
|
||||
countActiveFullAdministrators,
|
||||
isFullAdministrator,
|
||||
} from './bootstrap';
|
||||
|
||||
const AVATAR_FILE_PATTERN = /^[0-9a-f-]{36}\.webp$/i;
|
||||
type AvatarCleanup = { key: string } | { invalid: true } | null;
|
||||
|
||||
export class LastAdministratorDeletionError extends Error {
|
||||
constructor() {
|
||||
super('The final active full administrator cannot be deleted.');
|
||||
this.name = 'LastAdministratorDeletionError';
|
||||
}
|
||||
}
|
||||
|
||||
export async function softDeleteAccount(
|
||||
database: Database,
|
||||
@@ -10,113 +27,189 @@ export async function softDeleteAccount(
|
||||
reason: string | null,
|
||||
purgeSecurityData = false,
|
||||
): Promise<boolean> {
|
||||
const user = await (
|
||||
await database.prepare(`
|
||||
SELECT users.username,users.login_username,user_profiles.display_name,deleted_accounts.deleted_at
|
||||
FROM users LEFT JOIN user_profiles ON user_profiles.user_id=users.id
|
||||
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
|
||||
WHERE users.id=?
|
||||
`)
|
||||
).get(userId);
|
||||
if (!user || user.deleted_at) return false;
|
||||
const now = new Date().toISOString();
|
||||
const deletedUsername = `deleted-agent-${userId.slice(0, 12)}`;
|
||||
const deletedDisplayName = `Deleted Agent ${userId.replaceAll('-', '').slice(0, 8)}`;
|
||||
const deletion = database.transactionAsync(async (transaction) => {
|
||||
const user = await (
|
||||
await transaction.prepare(`
|
||||
SELECT users.username,users.login_username,user_profiles.display_name,
|
||||
user_status.blocked_at,user_status.banned_until,deleted_accounts.deleted_at
|
||||
FROM users LEFT JOIN user_profiles ON user_profiles.user_id=users.id
|
||||
LEFT JOIN user_status ON user_status.user_id=users.id
|
||||
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
|
||||
WHERE users.id=?
|
||||
`)
|
||||
).get(userId);
|
||||
if (!user || user.deleted_at) return false;
|
||||
const now = new Date().toISOString();
|
||||
const targetIsActive =
|
||||
!user.blocked_at &&
|
||||
(!user.banned_until || String(user.banned_until) <= now);
|
||||
if (
|
||||
targetIsActive &&
|
||||
(await isFullAdministrator(transaction as unknown as Database, userId)) &&
|
||||
(await countActiveFullAdministrators(
|
||||
transaction as unknown as Database,
|
||||
new Date(now),
|
||||
)) <= 1
|
||||
)
|
||||
throw new LastAdministratorDeletionError();
|
||||
const deletedUsername = `deleted-agent-${userId.slice(0, 12)}`;
|
||||
const deletedDisplayName = `Deleted Agent ${userId.replaceAll('-', '').slice(0, 8)}`;
|
||||
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO deleted_accounts(user_id,original_username,deleted_at,deleted_by,reason) VALUES(?,?,?,?,?)',
|
||||
)
|
||||
).run(
|
||||
userId,
|
||||
purgeSecurityData
|
||||
? deletedUsername
|
||||
: String(user.login_username ?? user.username),
|
||||
now,
|
||||
deletedBy,
|
||||
reason,
|
||||
);
|
||||
await (
|
||||
await database.prepare(
|
||||
`UPDATE users SET username=?,login_username=NULL,password_hash=NULL,discord_id=NULL,role='user' WHERE id=?`,
|
||||
)
|
||||
).run(deletedUsername, userId);
|
||||
await (
|
||||
await database.prepare(
|
||||
`INSERT INTO user_profiles(user_id,display_name,display_name_key,bio_markdown,is_public,bio_updated_at,updated_at)
|
||||
VALUES(?,?,?,?,0,?,?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
display_name=excluded.display_name,
|
||||
display_name_key=excluded.display_name_key,
|
||||
bio_markdown='',
|
||||
is_public=0,
|
||||
bio_updated_at=excluded.bio_updated_at,
|
||||
updated_at=excluded.updated_at`,
|
||||
)
|
||||
).run(
|
||||
userId,
|
||||
deletedDisplayName,
|
||||
displayNameKey(deletedDisplayName),
|
||||
'',
|
||||
now,
|
||||
now,
|
||||
);
|
||||
await (
|
||||
await database.prepare('DELETE FROM sessions WHERE user_id=?')
|
||||
).run(userId);
|
||||
await (
|
||||
await database.prepare('DELETE FROM magic_link_tokens WHERE user_id=?')
|
||||
).run(userId);
|
||||
await (
|
||||
await database.prepare('DELETE FROM webauthn_challenges WHERE user_id=?')
|
||||
).run(userId);
|
||||
await (
|
||||
await database.prepare('DELETE FROM passkeys WHERE user_id=?')
|
||||
).run(userId);
|
||||
await (
|
||||
await database.prepare('DELETE FROM user_emails WHERE user_id=?')
|
||||
).run(userId);
|
||||
await (
|
||||
await database.prepare('DELETE FROM oauth_states WHERE user_id=?')
|
||||
).run(userId);
|
||||
await (
|
||||
await database.prepare('DELETE FROM discord_profiles WHERE user_id=?')
|
||||
).run(userId);
|
||||
await (
|
||||
await database.prepare('DELETE FROM user_roles WHERE user_id=?')
|
||||
).run(userId);
|
||||
await (
|
||||
await database.prepare('DELETE FROM user_permissions WHERE user_id=?')
|
||||
).run(userId);
|
||||
await deleteUploadedAvatar(database, userId);
|
||||
if (purgeSecurityData) {
|
||||
await (
|
||||
await database.prepare('DELETE FROM login_events WHERE user_id=?')
|
||||
).run(userId);
|
||||
}
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,after_json,created_at) VALUES(?,?,?,?,?,?,?,?)',
|
||||
)
|
||||
).run(
|
||||
randomUUID(),
|
||||
deletedBy,
|
||||
'user_access',
|
||||
userId,
|
||||
'account.soft_deleted',
|
||||
purgeSecurityData
|
||||
? JSON.stringify({ purgedAtOwnerRequest: true })
|
||||
: JSON.stringify({
|
||||
username: user.username,
|
||||
displayName: user.display_name,
|
||||
}),
|
||||
JSON.stringify({
|
||||
username: deletedUsername,
|
||||
displayName: 'Deleted Agent',
|
||||
await transaction.prepare(
|
||||
'INSERT INTO deleted_accounts(user_id,original_username,deleted_at,deleted_by,reason) VALUES(?,?,?,?,?)',
|
||||
)
|
||||
).run(
|
||||
userId,
|
||||
purgeSecurityData
|
||||
? deletedUsername
|
||||
: String(user.login_username ?? user.username),
|
||||
now,
|
||||
deletedBy,
|
||||
reason,
|
||||
securityDataPurged: purgeSecurityData,
|
||||
}),
|
||||
now,
|
||||
);
|
||||
);
|
||||
await (
|
||||
await transaction.prepare(
|
||||
`UPDATE users SET username=?,login_username=NULL,password_hash=NULL,discord_id=NULL,role='user' WHERE id=?`,
|
||||
)
|
||||
).run(deletedUsername, userId);
|
||||
await (
|
||||
await transaction.prepare(
|
||||
`INSERT INTO user_profiles(user_id,display_name,display_name_key,bio_markdown,is_public,bio_updated_at,updated_at)
|
||||
VALUES(?,?,?,?,0,?,?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
display_name=excluded.display_name,
|
||||
display_name_key=excluded.display_name_key,
|
||||
bio_markdown='',
|
||||
is_public=0,
|
||||
bio_updated_at=excluded.bio_updated_at,
|
||||
updated_at=excluded.updated_at`,
|
||||
)
|
||||
).run(
|
||||
userId,
|
||||
deletedDisplayName,
|
||||
displayNameKey(deletedDisplayName),
|
||||
'',
|
||||
now,
|
||||
now,
|
||||
);
|
||||
const avatar = await (
|
||||
await transaction.prepare(
|
||||
'SELECT source,value FROM user_avatars WHERE user_id=? LIMIT 1',
|
||||
)
|
||||
).get(userId);
|
||||
const avatarCleanup: AvatarCleanup =
|
||||
avatar?.source === 'upload'
|
||||
? typeof avatar.value === 'string' &&
|
||||
AVATAR_FILE_PATTERN.test(avatar.value)
|
||||
? { key: avatar.value }
|
||||
: { invalid: true }
|
||||
: null;
|
||||
await (
|
||||
await transaction.prepare('DELETE FROM user_avatars WHERE user_id=?')
|
||||
).run(userId);
|
||||
await (
|
||||
await transaction.prepare(
|
||||
"UPDATE avatar_reviews SET status='removed',reviewed_at=COALESCE(reviewed_at,?) WHERE user_id=? AND status='pending'",
|
||||
)
|
||||
).run(now, userId);
|
||||
await (
|
||||
await transaction.prepare('DELETE FROM sessions WHERE user_id=?')
|
||||
).run(userId);
|
||||
await (
|
||||
await transaction.prepare('DELETE FROM magic_link_tokens WHERE user_id=?')
|
||||
).run(userId);
|
||||
await (
|
||||
await transaction.prepare(
|
||||
'DELETE FROM webauthn_challenges WHERE user_id=?',
|
||||
)
|
||||
).run(userId);
|
||||
await (
|
||||
await transaction.prepare('DELETE FROM passkeys WHERE user_id=?')
|
||||
).run(userId);
|
||||
await (
|
||||
await transaction.prepare('DELETE FROM user_emails WHERE user_id=?')
|
||||
).run(userId);
|
||||
await (
|
||||
await transaction.prepare('DELETE FROM oauth_states WHERE user_id=?')
|
||||
).run(userId);
|
||||
await (
|
||||
await transaction.prepare('DELETE FROM discord_profiles WHERE user_id=?')
|
||||
).run(userId);
|
||||
await (
|
||||
await transaction.prepare('DELETE FROM user_roles WHERE user_id=?')
|
||||
).run(userId);
|
||||
await (
|
||||
await transaction.prepare('DELETE FROM user_permissions WHERE user_id=?')
|
||||
).run(userId);
|
||||
|
||||
// Overwrite ciphertext and key metadata before deleting factor rows.
|
||||
await (
|
||||
await transaction.prepare(
|
||||
"UPDATE mfa_totp_factors SET state='revoked',encrypted_secret=?,encryption_key_id='erased',revoked_at=? WHERE user_id=?",
|
||||
)
|
||||
).run(`erased:${randomBytes(32).toString('base64url')}`, now, userId);
|
||||
await (
|
||||
await transaction.prepare('DELETE FROM mfa_totp_factors WHERE user_id=?')
|
||||
).run(userId);
|
||||
await (
|
||||
await transaction.prepare(
|
||||
'DELETE FROM mfa_recovery_codes WHERE user_id=?',
|
||||
)
|
||||
).run(userId);
|
||||
await (
|
||||
await transaction.prepare(
|
||||
'DELETE FROM authentication_transactions WHERE user_id=?',
|
||||
)
|
||||
).run(userId);
|
||||
|
||||
if (purgeSecurityData) {
|
||||
await (
|
||||
await transaction.prepare('DELETE FROM login_events WHERE user_id=?')
|
||||
).run(userId);
|
||||
}
|
||||
await recordIsacAudit({
|
||||
actorId: deletedBy,
|
||||
category: 'user_access',
|
||||
action: 'account.soft_deleted',
|
||||
entityId: userId,
|
||||
before: purgeSecurityData
|
||||
? { purgedAtOwnerRequest: true }
|
||||
: {
|
||||
username: user.username,
|
||||
displayName: user.display_name,
|
||||
},
|
||||
after: {
|
||||
username: deletedUsername,
|
||||
displayName: 'Deleted Agent',
|
||||
reason,
|
||||
securityDataPurged: purgeSecurityData,
|
||||
mfaFactorsPurged: true,
|
||||
recoveryCodesPurged: true,
|
||||
authenticationTransactionsPurged: true,
|
||||
},
|
||||
database: transaction as unknown as Database,
|
||||
createdAt: now,
|
||||
});
|
||||
return { deleted: true, avatarCleanup };
|
||||
});
|
||||
const committed = await deletion.immediate();
|
||||
if (!committed) return false;
|
||||
if (committed.avatarCleanup) {
|
||||
try {
|
||||
if ('invalid' in committed.avatarCleanup)
|
||||
throw new Error('invalid avatar storage key');
|
||||
await unlink(
|
||||
path.join(avatarStorageDirectory(), committed.avatarCleanup.key),
|
||||
);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
console.error(
|
||||
'[account-deletion] committed deletion; uploaded avatar cleanup failed.',
|
||||
{ userId: userId.slice(0, 200) },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
import { dev } from '$app/environment';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import type { Database } from '@tursodatabase/database';
|
||||
import type { Cookies } from '@sveltejs/kit';
|
||||
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
||||
|
||||
export const AUTHENTICATION_TRANSACTION_COOKIE = 'shd_auth_tx';
|
||||
export const AUTHENTICATION_TRANSACTION_COOKIE_PATH = '/auth/two-factor';
|
||||
export const AUTHENTICATION_TRANSACTION_SECONDS = 5 * 60;
|
||||
export const AUTHENTICATION_TRANSACTION_ATTEMPT_LIMIT = 5;
|
||||
|
||||
export const AUTHENTICATION_TRANSACTION_PROVIDERS = [
|
||||
'password',
|
||||
'discord',
|
||||
'magic_link',
|
||||
'passkey',
|
||||
] as const;
|
||||
export type AuthenticationTransactionProvider =
|
||||
(typeof AUTHENTICATION_TRANSACTION_PROVIDERS)[number];
|
||||
export type AuthenticationTransactionKind = 'login' | 'step_up';
|
||||
export type AuthenticationTransactionDatabase = Pick<
|
||||
Database,
|
||||
'prepare' | 'transactionAsync'
|
||||
>;
|
||||
export type AuthenticationTransactionTransaction = Pick<Database, 'prepare'>;
|
||||
|
||||
export type AuthenticationTransaction = {
|
||||
id: string;
|
||||
userId: string;
|
||||
provider: AuthenticationTransactionProvider | null;
|
||||
kind: AuthenticationTransactionKind;
|
||||
returnPath: string;
|
||||
requiredAal: 2;
|
||||
attemptCount: number;
|
||||
expiresAt: string;
|
||||
};
|
||||
|
||||
type AuthenticationTransactionRow = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
provider: AuthenticationTransactionProvider | null;
|
||||
kind: AuthenticationTransactionKind;
|
||||
return_path: string;
|
||||
required_aal: 2;
|
||||
attempt_count: number;
|
||||
expires_at: string;
|
||||
consumed_at: string | null;
|
||||
};
|
||||
|
||||
export class AuthenticationTransactionError extends Error {
|
||||
constructor(message = 'Unable to create the authentication transaction.') {
|
||||
super(message);
|
||||
this.name = 'AuthenticationTransactionError';
|
||||
}
|
||||
}
|
||||
|
||||
function hashToken(token: string): string {
|
||||
return createHash('sha256').update(token, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
function databaseOrDefault(
|
||||
database?: AuthenticationTransactionDatabase,
|
||||
): Promise<AuthenticationTransactionDatabase> {
|
||||
return database
|
||||
? Promise.resolve(database)
|
||||
: (getDatabase() as Promise<AuthenticationTransactionDatabase>);
|
||||
}
|
||||
|
||||
function nowDate(value: Date | string | number | undefined): Date {
|
||||
const date =
|
||||
value instanceof Date ? new Date(value) : new Date(value ?? Date.now());
|
||||
if (!Number.isFinite(date.getTime()))
|
||||
throw new AuthenticationTransactionError();
|
||||
return date;
|
||||
}
|
||||
|
||||
/** Accept only a same-origin path, never an absolute or protocol-relative URL. */
|
||||
export function validateAuthenticationReturnPath(value: string): string | null {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length < 1 ||
|
||||
value.length > 2048 ||
|
||||
!value.startsWith('/') ||
|
||||
value.startsWith('//') ||
|
||||
value.includes('\\') ||
|
||||
value.includes('#') ||
|
||||
/[\u0000-\u001f\u007f]/u.test(value)
|
||||
)
|
||||
return null;
|
||||
try {
|
||||
const decoded = decodeURIComponent(value);
|
||||
if (
|
||||
decoded.startsWith('//') ||
|
||||
decoded.includes('\\') ||
|
||||
/[\u0000-\u001f\u007f]/u.test(decoded)
|
||||
)
|
||||
return null;
|
||||
const parsed = new URL(value, 'https://shd.invalid');
|
||||
if (
|
||||
parsed.origin !== 'https://shd.invalid' ||
|
||||
!parsed.pathname.startsWith('/')
|
||||
)
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function cookieOptions() {
|
||||
return {
|
||||
path: AUTHENTICATION_TRANSACTION_COOKIE_PATH,
|
||||
httpOnly: true,
|
||||
sameSite: 'lax' as const,
|
||||
secure: !dev,
|
||||
maxAge: AUTHENTICATION_TRANSACTION_SECONDS,
|
||||
};
|
||||
}
|
||||
|
||||
export function clearAuthenticationTransactionCookie(cookies: Cookies): void {
|
||||
cookies.delete(AUTHENTICATION_TRANSACTION_COOKIE, {
|
||||
path: AUTHENTICATION_TRANSACTION_COOKIE_PATH,
|
||||
});
|
||||
}
|
||||
|
||||
function fromRow(row: AuthenticationTransactionRow): AuthenticationTransaction {
|
||||
return {
|
||||
id: String(row.id),
|
||||
userId: String(row.user_id),
|
||||
provider: row.provider ?? null,
|
||||
kind: row.kind,
|
||||
returnPath: String(row.return_path),
|
||||
requiredAal: 2,
|
||||
attemptCount: Number(row.attempt_count),
|
||||
expiresAt: String(row.expires_at),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createAuthenticationTransaction(
|
||||
cookies: Cookies,
|
||||
databaseInput: AuthenticationTransactionDatabase | undefined,
|
||||
options: {
|
||||
userId: string;
|
||||
kind: AuthenticationTransactionKind;
|
||||
provider?: AuthenticationTransactionProvider | null;
|
||||
returnPath: string;
|
||||
now?: Date | string | number;
|
||||
},
|
||||
): Promise<AuthenticationTransaction> {
|
||||
const returnPath = validateAuthenticationReturnPath(options.returnPath);
|
||||
if (
|
||||
!options.userId ||
|
||||
!returnPath ||
|
||||
(options.kind === 'login' && !options.provider)
|
||||
)
|
||||
throw new AuthenticationTransactionError();
|
||||
const database = await databaseOrDefault(databaseInput);
|
||||
const now = nowDate(options.now);
|
||||
const createdAt = now.toISOString();
|
||||
const expiresAt = new Date(
|
||||
now.getTime() + AUTHENTICATION_TRANSACTION_SECONDS * 1000,
|
||||
).toISOString();
|
||||
const id = randomUUID();
|
||||
const token = randomBytes(32).toString('base64url');
|
||||
await (
|
||||
await database.prepare(
|
||||
`INSERT INTO authentication_transactions(
|
||||
id,token_hash,user_id,provider,kind,return_path,required_aal,attempt_count,expires_at,created_at
|
||||
) VALUES(?,?,?,?,?,?,?,?,?,?)`,
|
||||
)
|
||||
).run(
|
||||
id,
|
||||
hashToken(token),
|
||||
options.userId,
|
||||
options.provider ?? null,
|
||||
options.kind,
|
||||
returnPath,
|
||||
2,
|
||||
0,
|
||||
expiresAt,
|
||||
createdAt,
|
||||
);
|
||||
cookies.set(AUTHENTICATION_TRANSACTION_COOKIE, token, cookieOptions());
|
||||
return {
|
||||
id,
|
||||
userId: options.userId,
|
||||
provider: options.provider ?? null,
|
||||
kind: options.kind,
|
||||
returnPath,
|
||||
requiredAal: 2,
|
||||
attemptCount: 0,
|
||||
expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
export const beginAuthenticationTransaction = createAuthenticationTransaction;
|
||||
|
||||
export async function getAuthenticationTransaction(
|
||||
cookies: Cookies,
|
||||
databaseInput?: AuthenticationTransactionDatabase,
|
||||
nowValue?: Date | string | number,
|
||||
): Promise<AuthenticationTransaction | null> {
|
||||
const token = cookies.get(AUTHENTICATION_TRANSACTION_COOKIE);
|
||||
if (!token) return null;
|
||||
const now = nowDate(nowValue).toISOString();
|
||||
const database = await databaseOrDefault(databaseInput);
|
||||
const row = (await (
|
||||
await database.prepare(
|
||||
`SELECT id,user_id,provider,kind,return_path,required_aal,attempt_count,expires_at,consumed_at
|
||||
FROM authentication_transactions
|
||||
WHERE token_hash=? AND consumed_at IS NULL AND expires_at>? AND attempt_count<?`,
|
||||
)
|
||||
).get(hashToken(token), now, AUTHENTICATION_TRANSACTION_ATTEMPT_LIMIT)) as
|
||||
AuthenticationTransactionRow | undefined;
|
||||
if (!row) {
|
||||
clearAuthenticationTransactionCookie(cookies);
|
||||
return null;
|
||||
}
|
||||
return fromRow(row);
|
||||
}
|
||||
|
||||
/** Atomically account for one failed factor attempt. */
|
||||
export async function recordAuthenticationAttempt(
|
||||
cookies: Cookies,
|
||||
databaseInput?: AuthenticationTransactionDatabase,
|
||||
nowValue?: Date | string | number,
|
||||
): Promise<{ accepted: boolean; attemptCount: number; locked: boolean }> {
|
||||
const token = cookies.get(AUTHENTICATION_TRANSACTION_COOKIE);
|
||||
if (!token) return { accepted: false, attemptCount: 0, locked: false };
|
||||
const database = await databaseOrDefault(databaseInput);
|
||||
const now = nowDate(nowValue).toISOString();
|
||||
const attempt = database.transactionAsync(async (transaction) => {
|
||||
const row = (await (
|
||||
await transaction.prepare(
|
||||
'SELECT id,attempt_count FROM authentication_transactions WHERE token_hash=? AND consumed_at IS NULL AND expires_at>? AND attempt_count<?',
|
||||
)
|
||||
).get(hashToken(token), now, AUTHENTICATION_TRANSACTION_ATTEMPT_LIMIT)) as
|
||||
{ id: string; attempt_count: number } | undefined;
|
||||
if (!row) return null;
|
||||
const updated = await (
|
||||
await transaction.prepare(
|
||||
'UPDATE authentication_transactions SET attempt_count=attempt_count+1 WHERE id=? AND consumed_at IS NULL AND attempt_count<?',
|
||||
)
|
||||
).run(row.id, AUTHENTICATION_TRANSACTION_ATTEMPT_LIMIT);
|
||||
return updated.changes === 1 ? Number(row.attempt_count) + 1 : null;
|
||||
});
|
||||
const attemptCount = await attempt.immediate();
|
||||
if (attemptCount === null) {
|
||||
clearAuthenticationTransactionCookie(cookies);
|
||||
return { accepted: false, attemptCount: 0, locked: true };
|
||||
}
|
||||
return {
|
||||
accepted: true,
|
||||
attemptCount,
|
||||
locked: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a transaction while the caller's immediate transaction holds the
|
||||
* write claim. The conditional consume must happen before that transaction
|
||||
* commits.
|
||||
*/
|
||||
export async function claimAuthenticationTransactionInTransaction(
|
||||
transaction: AuthenticationTransactionTransaction,
|
||||
cookies: Cookies,
|
||||
nowValue?: Date | string | number,
|
||||
): Promise<AuthenticationTransaction | null> {
|
||||
const token = cookies.get(AUTHENTICATION_TRANSACTION_COOKIE);
|
||||
if (!token) return null;
|
||||
const now = nowDate(nowValue).toISOString();
|
||||
const row = (await (
|
||||
await transaction.prepare(
|
||||
`SELECT id,user_id,provider,kind,return_path,required_aal,attempt_count,expires_at,consumed_at
|
||||
FROM authentication_transactions
|
||||
WHERE token_hash=? AND consumed_at IS NULL AND expires_at>? AND attempt_count<?`,
|
||||
)
|
||||
).get(hashToken(token), now, AUTHENTICATION_TRANSACTION_ATTEMPT_LIMIT)) as
|
||||
AuthenticationTransactionRow | undefined;
|
||||
return row ? fromRow(row) : null;
|
||||
}
|
||||
|
||||
/** Conditionally consume a transaction already loaded in this transaction. */
|
||||
export async function consumeAuthenticationTransactionInTransaction(
|
||||
transaction: AuthenticationTransactionTransaction,
|
||||
cookies: Cookies,
|
||||
transactionId: string,
|
||||
nowValue?: Date | string | number,
|
||||
): Promise<boolean> {
|
||||
const token = cookies.get(AUTHENTICATION_TRANSACTION_COOKIE);
|
||||
if (!token) return false;
|
||||
const now = nowDate(nowValue).toISOString();
|
||||
const consumed = await (
|
||||
await transaction.prepare(
|
||||
`UPDATE authentication_transactions SET consumed_at=?
|
||||
WHERE id=? AND token_hash=? AND consumed_at IS NULL AND expires_at>? AND attempt_count<?`,
|
||||
)
|
||||
).run(
|
||||
now,
|
||||
transactionId,
|
||||
hashToken(token),
|
||||
now,
|
||||
AUTHENTICATION_TRANSACTION_ATTEMPT_LIMIT,
|
||||
);
|
||||
return consumed.changes === 1;
|
||||
}
|
||||
|
||||
/** Consume a transaction in one conditional write; replay cannot succeed. */
|
||||
export async function consumeAuthenticationTransaction(
|
||||
cookies: Cookies,
|
||||
databaseInput?: AuthenticationTransactionDatabase,
|
||||
nowValue?: Date | string | number,
|
||||
): Promise<AuthenticationTransaction | null> {
|
||||
const token = cookies.get(AUTHENTICATION_TRANSACTION_COOKIE);
|
||||
if (!token) return null;
|
||||
const database = await databaseOrDefault(databaseInput);
|
||||
const now = nowDate(nowValue).toISOString();
|
||||
const consume = database.transactionAsync(async (transaction) => {
|
||||
const row = await claimAuthenticationTransactionInTransaction(
|
||||
transaction,
|
||||
cookies,
|
||||
now,
|
||||
);
|
||||
if (!row) return null;
|
||||
const consumed = await consumeAuthenticationTransactionInTransaction(
|
||||
transaction,
|
||||
cookies,
|
||||
row.id,
|
||||
now,
|
||||
);
|
||||
return consumed ? row : null;
|
||||
});
|
||||
const result = await consume.immediate();
|
||||
clearAuthenticationTransactionCookie(cookies);
|
||||
return result;
|
||||
}
|
||||
|
||||
export const consumeAuthTransaction = consumeAuthenticationTransaction;
|
||||
@@ -0,0 +1,182 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { connect, type Database } from '@tursodatabase/database';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
audit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/audit', () => ({
|
||||
recordIsacAudit: mocks.audit,
|
||||
}));
|
||||
|
||||
import { applyAuthorityChange } from './authority-change';
|
||||
import type { AuthUser } from './session';
|
||||
|
||||
const operator = {
|
||||
id: 'operator-1',
|
||||
aal: 2,
|
||||
authLevel: 2,
|
||||
aal2VerifiedAt: new Date().toISOString(),
|
||||
} as AuthUser;
|
||||
|
||||
async function createDatabase(): Promise<Database> {
|
||||
const database = await connect(':memory:');
|
||||
await database.exec(`
|
||||
CREATE TABLE users(id TEXT PRIMARY KEY, role TEXT NOT NULL);
|
||||
CREATE TABLE roles(id TEXT PRIMARY KEY);
|
||||
CREATE TABLE permissions(id TEXT PRIMARY KEY, name TEXT NOT NULL);
|
||||
CREATE TABLE role_permissions(role_id TEXT, permission_id TEXT);
|
||||
CREATE TABLE user_roles(user_id TEXT, role_id TEXT, PRIMARY KEY(user_id,role_id));
|
||||
CREATE TABLE user_permissions(user_id TEXT, permission_id TEXT);
|
||||
CREATE TABLE user_status(user_id TEXT PRIMARY KEY, blocked_at TEXT, banned_until TEXT);
|
||||
CREATE TABLE deleted_accounts(user_id TEXT PRIMARY KEY);
|
||||
CREATE TABLE sessions(id TEXT PRIMARY KEY, user_id TEXT);
|
||||
CREATE TABLE mfa_totp_factors(id TEXT PRIMARY KEY, user_id TEXT, state TEXT);
|
||||
CREATE TABLE mfa_recovery_codes(id TEXT PRIMARY KEY, user_id TEXT, consumed_at TEXT, revoked_at TEXT);
|
||||
INSERT INTO users VALUES('operator-1','user'),('target-1','user');
|
||||
INSERT INTO roles VALUES('role-admin'),('role-user');
|
||||
INSERT INTO permissions VALUES('permission-system-admin','system.admin'),('permission-users-view','users.view');
|
||||
INSERT INTO role_permissions VALUES('role-admin','permission-system-admin'),('role-user','permission-users-view');
|
||||
INSERT INTO user_roles VALUES('operator-1','role-admin');
|
||||
INSERT INTO mfa_totp_factors VALUES('operator-factor','operator-1','active');
|
||||
INSERT INTO sessions VALUES('target-session','target-1');
|
||||
`);
|
||||
return database;
|
||||
}
|
||||
|
||||
const access = (roleIds: string[], permissionIds: string[]) => ({
|
||||
roleIds,
|
||||
permissionIds,
|
||||
});
|
||||
|
||||
describe('shared authority changes', () => {
|
||||
let database: Database;
|
||||
|
||||
beforeEach(async () => {
|
||||
database = await createDatabase();
|
||||
mocks.audit.mockReset();
|
||||
mocks.audit.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(async () => database.close());
|
||||
|
||||
it('cannot revert an administrator grant by removing the final active authority', async () => {
|
||||
await expect(
|
||||
applyAuthorityChange({
|
||||
database,
|
||||
operator,
|
||||
targetId: 'operator-1',
|
||||
...access([], []),
|
||||
expectedCurrent: access(['role-admin'], []),
|
||||
}),
|
||||
).rejects.toMatchObject({ status: 409 });
|
||||
|
||||
const role = await (
|
||||
await database.prepare('SELECT role_id FROM user_roles WHERE user_id=?')
|
||||
).get('operator-1');
|
||||
expect(role.role_id).toBe('role-admin');
|
||||
});
|
||||
|
||||
it('requires an MFA-ready target when reversion restores administrator authority', async () => {
|
||||
await expect(
|
||||
applyAuthorityChange({
|
||||
database,
|
||||
operator,
|
||||
targetId: 'target-1',
|
||||
...access(['role-admin'], []),
|
||||
expectedCurrent: access([], []),
|
||||
}),
|
||||
).rejects.toMatchObject({ status: 400 });
|
||||
|
||||
const role = await (
|
||||
await database.prepare('SELECT role_id FROM user_roles WHERE user_id=?')
|
||||
).get('target-1');
|
||||
expect(role).toBeUndefined();
|
||||
});
|
||||
|
||||
it('requires recent AAL2 authentication for administrator reversion', async () => {
|
||||
await database.exec(`
|
||||
INSERT INTO mfa_totp_factors VALUES('target-factor','target-1','active');
|
||||
INSERT INTO mfa_recovery_codes VALUES('target-code','target-1',NULL,NULL);
|
||||
`);
|
||||
|
||||
await expect(
|
||||
applyAuthorityChange({
|
||||
database,
|
||||
operator: {
|
||||
...operator,
|
||||
aal2VerifiedAt: new Date(Date.now() - 11 * 60_000).toISOString(),
|
||||
} as never,
|
||||
targetId: 'target-1',
|
||||
...access(['role-admin'], []),
|
||||
expectedCurrent: access([], []),
|
||||
}),
|
||||
).rejects.toMatchObject({ status: 403 });
|
||||
});
|
||||
|
||||
it('revokes sessions and appends reversion audit evidence in the transaction', async () => {
|
||||
await database.exec(`
|
||||
INSERT INTO mfa_totp_factors VALUES('target-factor','target-1','active');
|
||||
INSERT INTO mfa_recovery_codes VALUES('target-code','target-1',NULL,NULL);
|
||||
`);
|
||||
const result = await applyAuthorityChange({
|
||||
database,
|
||||
operator,
|
||||
targetId: 'target-1',
|
||||
...access(['role-admin'], []),
|
||||
expectedCurrent: access([], []),
|
||||
audit: {
|
||||
actorId: 'operator-1',
|
||||
category: 'audit_control',
|
||||
action: 'audit.reverted',
|
||||
entityId: 'event-1',
|
||||
before: access([], []),
|
||||
after: {
|
||||
originalEventId: 'event-1',
|
||||
restored: access(['role-admin'], []),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.after).toEqual(access(['role-admin'], []));
|
||||
expect(
|
||||
await (
|
||||
await database.prepare('SELECT COUNT(*) AS count FROM sessions')
|
||||
).get(),
|
||||
).toMatchObject({ count: 0 });
|
||||
expect(mocks.audit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'audit.reverted',
|
||||
entityId: 'event-1',
|
||||
database,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('rolls back authority and session changes when audit append fails', async () => {
|
||||
await database.exec(`
|
||||
INSERT INTO mfa_totp_factors VALUES('target-factor','target-1','active');
|
||||
INSERT INTO mfa_recovery_codes VALUES('target-code','target-1',NULL,NULL);
|
||||
`);
|
||||
mocks.audit.mockRejectedValueOnce(new Error('audit failed'));
|
||||
|
||||
await expect(
|
||||
applyAuthorityChange({
|
||||
database,
|
||||
operator,
|
||||
targetId: 'target-1',
|
||||
...access(['role-admin'], []),
|
||||
expectedCurrent: access([], []),
|
||||
}),
|
||||
).rejects.toThrow('audit failed');
|
||||
|
||||
const role = await (
|
||||
await database.prepare('SELECT role_id FROM user_roles WHERE user_id=?')
|
||||
).get('target-1');
|
||||
const sessions = await (
|
||||
await database.prepare('SELECT COUNT(*) AS count FROM sessions')
|
||||
).get();
|
||||
expect(role).toBeUndefined();
|
||||
expect(Number(sessions.count)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
import type { Database } from '@tursodatabase/database';
|
||||
import type { AuthUser } from './session';
|
||||
import { hasRecentAal2, RECENT_AAL2_WINDOW_MS } from './authorization';
|
||||
import { recordIsacAudit } from '$lib/server/audit';
|
||||
import {
|
||||
countActiveFullAdministrators,
|
||||
hasActiveTotpFactor,
|
||||
isFullAdministrator,
|
||||
isMfaReady,
|
||||
} from './bootstrap';
|
||||
|
||||
export type AuthoritySnapshot = {
|
||||
roleIds: string[];
|
||||
permissionIds: string[];
|
||||
};
|
||||
|
||||
type AuditValue = Record<string, unknown> | unknown[] | null;
|
||||
|
||||
export type AuthorityAudit = {
|
||||
actorId: string;
|
||||
category: string;
|
||||
action: string;
|
||||
entityId: string;
|
||||
before?: AuditValue;
|
||||
after?: AuditValue;
|
||||
};
|
||||
|
||||
export type AuthorityChangeInput = {
|
||||
database: Database;
|
||||
operator: AuthUser;
|
||||
targetId: string;
|
||||
roleIds: string[];
|
||||
permissionIds: string[];
|
||||
expectedCurrent?: AuthoritySnapshot;
|
||||
audit?: AuthorityAudit;
|
||||
};
|
||||
|
||||
export type AuthorityChangeResult = {
|
||||
before: AuthoritySnapshot;
|
||||
after: AuthoritySnapshot;
|
||||
sessionsRevoked: number;
|
||||
};
|
||||
|
||||
export class AuthorityChangeError extends Error {
|
||||
readonly status: 400 | 403 | 404 | 409;
|
||||
|
||||
constructor(status: 400 | 403 | 404 | 409, message: string) {
|
||||
super(message);
|
||||
this.name = 'AuthorityChangeError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = (values: string[]) => [...values].sort();
|
||||
|
||||
function sameSnapshot(
|
||||
left: AuthoritySnapshot,
|
||||
right: AuthoritySnapshot,
|
||||
): boolean {
|
||||
return (
|
||||
JSON.stringify({
|
||||
roleIds: sorted(left.roleIds),
|
||||
permissionIds: sorted(left.permissionIds),
|
||||
}) ===
|
||||
JSON.stringify({
|
||||
roleIds: sorted(right.roleIds),
|
||||
permissionIds: sorted(right.permissionIds),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function isRecentAal2(user: AuthUser): boolean {
|
||||
return hasRecentAal2(user, Date.now());
|
||||
}
|
||||
|
||||
export async function applyAuthorityChange(
|
||||
input: AuthorityChangeInput,
|
||||
): Promise<AuthorityChangeResult> {
|
||||
const database = input.database;
|
||||
await database.exec('BEGIN IMMEDIATE');
|
||||
let transactionOpen = true;
|
||||
const rollback = async () => {
|
||||
if (!transactionOpen) return;
|
||||
transactionOpen = false;
|
||||
await database.exec('ROLLBACK');
|
||||
};
|
||||
|
||||
try {
|
||||
const operatorIsFullAdministrator =
|
||||
(await isFullAdministrator(database, input.operator.id)) &&
|
||||
input.operator.aal === 2 &&
|
||||
(await hasActiveTotpFactor(database, input.operator.id));
|
||||
const target = await (
|
||||
await database.prepare('SELECT id FROM users WHERE id=?')
|
||||
).get(input.targetId);
|
||||
if (!target) throw new AuthorityChangeError(404, 'User not found.');
|
||||
|
||||
const roleIds = [...new Set(input.roleIds.map(String))];
|
||||
const permissionIds = [...new Set(input.permissionIds.map(String))];
|
||||
const validRoles = roleIds.length
|
||||
? Number(
|
||||
(
|
||||
await (
|
||||
await database.prepare(
|
||||
`SELECT COUNT(*) AS count FROM roles WHERE id IN (${roleIds.map(() => '?').join(',')})`,
|
||||
)
|
||||
).get(...roleIds)
|
||||
)?.count,
|
||||
)
|
||||
: 0;
|
||||
const validPermissionRows = permissionIds.length
|
||||
? await (
|
||||
await database.prepare(
|
||||
`SELECT id,name FROM permissions WHERE id IN (${permissionIds.map(() => '?').join(',')})`,
|
||||
)
|
||||
).all(...permissionIds)
|
||||
: [];
|
||||
if (
|
||||
validRoles !== roleIds.length ||
|
||||
validPermissionRows.length !== permissionIds.length
|
||||
)
|
||||
throw new AuthorityChangeError(
|
||||
400,
|
||||
'One or more access selections are invalid.',
|
||||
);
|
||||
|
||||
const before: AuthoritySnapshot = {
|
||||
roleIds: (
|
||||
await (
|
||||
await database.prepare(
|
||||
'SELECT role_id FROM user_roles WHERE user_id=? ORDER BY role_id',
|
||||
)
|
||||
).all(input.targetId)
|
||||
).map((row) => String(row.role_id)),
|
||||
permissionIds: (
|
||||
await (
|
||||
await database.prepare(
|
||||
'SELECT permission_id FROM user_permissions WHERE user_id=? ORDER BY permission_id',
|
||||
)
|
||||
).all(input.targetId)
|
||||
).map((row) => String(row.permission_id)),
|
||||
};
|
||||
if (input.expectedCurrent && !sameSnapshot(before, input.expectedCurrent))
|
||||
throw new AuthorityChangeError(
|
||||
409,
|
||||
'The user’s access changed after this event. Reversion was blocked.',
|
||||
);
|
||||
|
||||
const currentRoleSystemAdmin = Boolean(
|
||||
await (
|
||||
await database.prepare(`
|
||||
SELECT 1
|
||||
FROM user_roles
|
||||
JOIN role_permissions ON role_permissions.role_id=user_roles.role_id
|
||||
JOIN permissions ON permissions.id=role_permissions.permission_id
|
||||
WHERE user_roles.user_id=? AND permissions.name='system.admin'
|
||||
LIMIT 1
|
||||
`)
|
||||
).get(input.targetId),
|
||||
);
|
||||
const currentDirectSystemAdmin = before.permissionIds.includes(
|
||||
'permission-system-admin',
|
||||
);
|
||||
const currentSystemAdmin =
|
||||
currentRoleSystemAdmin || currentDirectSystemAdmin;
|
||||
const requestedDirectSystemAdmin = validPermissionRows.some(
|
||||
(permission) => String(permission.name) === 'system.admin',
|
||||
);
|
||||
const requestedRoleSystemAdmin = Boolean(
|
||||
roleIds.length &&
|
||||
(await (
|
||||
await database.prepare(`
|
||||
SELECT 1
|
||||
FROM role_permissions
|
||||
JOIN permissions ON permissions.id=role_permissions.permission_id
|
||||
WHERE role_permissions.role_id IN (${roleIds.map(() => '?').join(',')})
|
||||
AND permissions.name='system.admin'
|
||||
LIMIT 1
|
||||
`)
|
||||
).get(...roleIds)),
|
||||
);
|
||||
const requestedSystemAdmin =
|
||||
requestedDirectSystemAdmin || requestedRoleSystemAdmin;
|
||||
const requestedCanonicalAdministrator = roleIds.includes('role-admin');
|
||||
const targetIsFullAdministrator = await isFullAdministrator(
|
||||
database,
|
||||
input.targetId,
|
||||
);
|
||||
const targetHadAdministratorAuthority =
|
||||
targetIsFullAdministrator || before.roleIds.includes('role-admin');
|
||||
const changesAdministratorAccess =
|
||||
targetIsFullAdministrator ||
|
||||
currentSystemAdmin !== requestedSystemAdmin ||
|
||||
currentDirectSystemAdmin !== requestedDirectSystemAdmin ||
|
||||
requestedCanonicalAdministrator !== before.roleIds.includes('role-admin');
|
||||
|
||||
if (changesAdministratorAccess && !operatorIsFullAdministrator)
|
||||
throw new AuthorityChangeError(
|
||||
403,
|
||||
'Only a full administrator can grant, remove, or modify administrator access.',
|
||||
);
|
||||
if (changesAdministratorAccess && !isRecentAal2(input.operator))
|
||||
throw new AuthorityChangeError(
|
||||
403,
|
||||
`Recent AAL2 authentication is required for administrator changes within ${RECENT_AAL2_WINDOW_MS / 60_000} minutes.`,
|
||||
);
|
||||
if (
|
||||
(requestedSystemAdmin || requestedCanonicalAdministrator) &&
|
||||
(!targetHadAdministratorAuthority ||
|
||||
(requestedDirectSystemAdmin && !currentDirectSystemAdmin)) &&
|
||||
!(await isMfaReady(database, input.targetId))
|
||||
)
|
||||
throw new AuthorityChangeError(
|
||||
400,
|
||||
'The target must have an active TOTP factor and an unused recovery code before receiving administrator authority.',
|
||||
);
|
||||
|
||||
await (
|
||||
await database.prepare('DELETE FROM user_roles WHERE user_id=?')
|
||||
).run(input.targetId);
|
||||
await (
|
||||
await database.prepare('DELETE FROM user_permissions WHERE user_id=?')
|
||||
).run(input.targetId);
|
||||
for (const roleId of roleIds)
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO user_roles(user_id,role_id) VALUES(?,?)',
|
||||
)
|
||||
).run(input.targetId, roleId);
|
||||
for (const permissionId of permissionIds)
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO user_permissions(user_id,permission_id) VALUES(?,?)',
|
||||
)
|
||||
).run(input.targetId, permissionId);
|
||||
|
||||
let sessionsRevoked = 0;
|
||||
if (changesAdministratorAccess) {
|
||||
const revoked = await (
|
||||
await database.prepare('DELETE FROM sessions WHERE user_id=?')
|
||||
).run(input.targetId);
|
||||
sessionsRevoked = Number(revoked.changes ?? 0);
|
||||
}
|
||||
if ((await countActiveFullAdministrators(database)) < 1)
|
||||
throw new AuthorityChangeError(
|
||||
409,
|
||||
'The final active full administrator cannot lose administrator access.',
|
||||
);
|
||||
|
||||
const after: AuthoritySnapshot = { roleIds, permissionIds };
|
||||
const audit = input.audit ?? {
|
||||
actorId: input.operator.id,
|
||||
category: 'user_access',
|
||||
action: 'access.updated',
|
||||
entityId: input.targetId,
|
||||
before,
|
||||
after,
|
||||
};
|
||||
await recordIsacAudit({
|
||||
...audit,
|
||||
before: audit.before === undefined ? before : audit.before,
|
||||
after: audit.after === undefined ? after : audit.after,
|
||||
database,
|
||||
});
|
||||
await database.exec('COMMIT');
|
||||
transactionOpen = false;
|
||||
return { before, after, sessionsRevoked };
|
||||
} catch (error) {
|
||||
await rollback();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
database: { prepare: vi.fn() },
|
||||
isFullAdministrator: vi.fn(),
|
||||
hasActiveTotpFactor: vi.fn(),
|
||||
isMfaReady: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/database', () => ({
|
||||
getDatabase: vi.fn(async () => mocks.database),
|
||||
}));
|
||||
vi.mock('./bootstrap', () => ({
|
||||
isFullAdministrator: mocks.isFullAdministrator,
|
||||
hasActiveTotpFactor: mocks.hasActiveTotpFactor,
|
||||
isMfaReady: mocks.isMfaReady,
|
||||
}));
|
||||
|
||||
import {
|
||||
getPermissions,
|
||||
hasRecentAal2,
|
||||
requireRecentAal2,
|
||||
} from './authorization';
|
||||
|
||||
const user = (aal: 1 | 2, aal2VerifiedAt: string | null = null) =>
|
||||
({
|
||||
id: 'user-1',
|
||||
aal,
|
||||
authLevel: aal,
|
||||
aal2VerifiedAt,
|
||||
}) as never;
|
||||
|
||||
describe('privileged authorization assurance', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.isFullAdministrator.mockResolvedValue(false);
|
||||
mocks.hasActiveTotpFactor.mockResolvedValue(false);
|
||||
mocks.database.prepare.mockResolvedValue({
|
||||
all: vi.fn(async () => [
|
||||
{ name: 'system.admin' },
|
||||
{ name: 'users.view' },
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it('does not turn an AAL1 or unenrolled administrator into wildcard access', async () => {
|
||||
mocks.isFullAdministrator.mockResolvedValue(true);
|
||||
const permissions = await getPermissions(user(1));
|
||||
expect(permissions).toContain('users.view');
|
||||
expect(permissions).toContain('isac.access');
|
||||
expect(permissions).not.toContain('*');
|
||||
expect(permissions).not.toContain('system.admin');
|
||||
});
|
||||
|
||||
it('requires both canonical administration, active TOTP, and AAL2 for wildcard access', async () => {
|
||||
mocks.isFullAdministrator.mockResolvedValue(true);
|
||||
mocks.hasActiveTotpFactor.mockResolvedValue(true);
|
||||
const permissions = await getPermissions(user(2));
|
||||
expect(permissions).toEqual(['*']);
|
||||
});
|
||||
|
||||
it('does not treat a direct system.admin grant as unrestricted access', async () => {
|
||||
const permissions = await getPermissions(user(2));
|
||||
expect(permissions).toContain('users.view');
|
||||
expect(permissions).not.toContain('*');
|
||||
expect(permissions).not.toContain('system.admin');
|
||||
});
|
||||
|
||||
it('recognizes only AAL2 evidence from the last ten minutes', () => {
|
||||
const now = Date.parse('2026-08-07T12:00:00.000Z');
|
||||
expect(hasRecentAal2(user(1, '2026-08-07T11:59:00.000Z'), now)).toBe(false);
|
||||
expect(hasRecentAal2(user(2, '2026-08-07T11:59:00.000Z'), now)).toBe(true);
|
||||
expect(hasRecentAal2(user(2, '2026-08-07T11:49:59.999Z'), now)).toBe(false);
|
||||
expect(() =>
|
||||
requireRecentAal2(user(1, '2026-08-07T11:59:00.000Z'), now),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,13 @@
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import type { AuthUser } from './session';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import {
|
||||
hasActiveTotpFactor,
|
||||
isFullAdministrator,
|
||||
isMfaReady,
|
||||
} from './bootstrap';
|
||||
|
||||
export const RECENT_AAL2_WINDOW_MS = 10 * 60 * 1000;
|
||||
|
||||
const REGISTERED_AGENT_PERMISSIONS = [
|
||||
'isac.access',
|
||||
@@ -10,8 +17,14 @@ const REGISTERED_AGENT_PERMISSIONS = [
|
||||
|
||||
export async function getPermissions(user: AuthUser | null): Promise<string[]> {
|
||||
if (!user) return [];
|
||||
if (user.role === 'admin') return ['*'];
|
||||
const database = await getDatabase();
|
||||
const canonicalAdministrator = await isFullAdministrator(database, user.id);
|
||||
if (
|
||||
canonicalAdministrator &&
|
||||
user.aal === 2 &&
|
||||
(await hasActiveTotpFactor(database, user.id))
|
||||
)
|
||||
return ['*'];
|
||||
const rows = await (
|
||||
await database.prepare(`
|
||||
SELECT permissions.name FROM permissions
|
||||
@@ -27,10 +40,12 @@ export async function getPermissions(user: AuthUser | null): Promise<string[]> {
|
||||
const permissions = [
|
||||
...new Set([
|
||||
...REGISTERED_AGENT_PERMISSIONS,
|
||||
...rows.map((row) => String(row.name)),
|
||||
...rows
|
||||
.map((row) => String(row.name))
|
||||
.filter((permission) => permission !== 'system.admin'),
|
||||
]),
|
||||
];
|
||||
return permissions.includes('system.admin') ? ['*'] : permissions;
|
||||
return permissions;
|
||||
}
|
||||
|
||||
export async function hasPermission(
|
||||
@@ -67,7 +82,60 @@ export async function requireDataView(
|
||||
return user;
|
||||
}
|
||||
|
||||
export function requireAdmin(user: AuthUser | null): asserts user is AuthUser {
|
||||
export async function requireAdmin(user: AuthUser | null): Promise<AuthUser> {
|
||||
if (!user) error(401, 'Sign in required');
|
||||
if (user.role !== 'admin') error(403, 'Administrator permission required');
|
||||
const database = await getDatabase();
|
||||
if (!(await isFullAdministrator(database, user.id)))
|
||||
error(403, 'Administrator permission required');
|
||||
if (!(await hasActiveTotpFactor(database, user.id)))
|
||||
error(403, 'Administrator MFA enrollment required');
|
||||
requireAal2(user);
|
||||
return user;
|
||||
}
|
||||
|
||||
export function hasAal2(user: AuthUser | null): boolean {
|
||||
return user?.aal === 2;
|
||||
}
|
||||
|
||||
export function hasRecentAal2(
|
||||
user: AuthUser | null,
|
||||
now = Date.now(),
|
||||
): boolean {
|
||||
if (!hasAal2(user) || !user?.aal2VerifiedAt) return false;
|
||||
const verifiedAt = Date.parse(user.aal2VerifiedAt);
|
||||
return (
|
||||
Number.isFinite(verifiedAt) &&
|
||||
verifiedAt <= now &&
|
||||
now - verifiedAt <= RECENT_AAL2_WINDOW_MS
|
||||
);
|
||||
}
|
||||
|
||||
export function requireAal2(user: AuthUser | null): AuthUser {
|
||||
if (!user) error(401, 'Sign in required');
|
||||
if (!hasAal2(user)) error(403, 'AAL2 authentication required');
|
||||
return user;
|
||||
}
|
||||
|
||||
export function requireRecentAal2(
|
||||
user: AuthUser | null,
|
||||
now = Date.now(),
|
||||
): AuthUser {
|
||||
if (!user) error(401, 'Sign in required');
|
||||
if (!hasRecentAal2(user, now))
|
||||
error(403, 'Recent AAL2 authentication required');
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function isAdministratorMfaReady(
|
||||
user: AuthUser | null,
|
||||
): Promise<boolean> {
|
||||
if (!user) return false;
|
||||
return isMfaReady(await getDatabase(), user.id);
|
||||
}
|
||||
|
||||
export async function targetCanReceiveAdministratorAuthority(
|
||||
database: Parameters<typeof isMfaReady>[0],
|
||||
userId: string,
|
||||
): Promise<boolean> {
|
||||
return isMfaReady(database, userId);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,91 @@
|
||||
import type { Database } from '@tursodatabase/database';
|
||||
|
||||
/**
|
||||
* Atomically promotes the authenticated user when no administrator exists.
|
||||
* The conditional UPDATE ensures concurrent first sign-ins cannot both win.
|
||||
* Returns whether the account has the canonical unrestricted administrator
|
||||
* relationship. The role column is a legacy projection and is not authority.
|
||||
* Direct system.admin grants are intentionally not treated as full
|
||||
* administration; they cannot bootstrap or escalate an account.
|
||||
*/
|
||||
export async function ensureBootstrapAdmin(
|
||||
export async function isFullAdministrator(
|
||||
database: Database,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
const promote = await database.prepare(`
|
||||
UPDATE users
|
||||
SET role = 'admin'
|
||||
WHERE id = ?
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM users WHERE role = 'admin'
|
||||
)
|
||||
`);
|
||||
await promote.run(userId);
|
||||
): Promise<boolean> {
|
||||
const row = await (
|
||||
await database.prepare(`
|
||||
SELECT 1
|
||||
FROM users
|
||||
WHERE users.id=?
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM user_roles
|
||||
WHERE user_roles.user_id=users.id
|
||||
AND user_roles.role_id='role-admin'
|
||||
)
|
||||
LIMIT 1
|
||||
`)
|
||||
).get(userId);
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
/** Count active accounts holding the canonical unrestricted relationship. */
|
||||
export async function countActiveFullAdministrators(
|
||||
database: Database,
|
||||
now = new Date(),
|
||||
): Promise<number> {
|
||||
const row = await (
|
||||
await database.prepare(`
|
||||
SELECT COUNT(DISTINCT users.id) AS count
|
||||
FROM users
|
||||
LEFT JOIN user_status ON user_status.user_id=users.id
|
||||
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
|
||||
WHERE deleted_accounts.user_id IS NULL
|
||||
AND user_status.blocked_at IS NULL
|
||||
AND (
|
||||
user_status.banned_until IS NULL
|
||||
OR user_status.banned_until<=?
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM user_roles
|
||||
WHERE user_roles.user_id=users.id AND user_roles.role_id='role-admin'
|
||||
)
|
||||
`)
|
||||
).get(now.toISOString());
|
||||
const count = Number(row?.count);
|
||||
if (!Number.isSafeInteger(count) || count < 0)
|
||||
throw new Error('Unable to determine active administrator count.');
|
||||
return count;
|
||||
}
|
||||
|
||||
export async function hasActiveTotpFactor(
|
||||
database: Database,
|
||||
userId: string,
|
||||
): Promise<boolean> {
|
||||
const row = await (
|
||||
await database.prepare(
|
||||
"SELECT 1 FROM mfa_totp_factors WHERE user_id=? AND state='active' LIMIT 1",
|
||||
)
|
||||
).get(userId);
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
export async function hasUnusedRecoveryCode(
|
||||
database: Database,
|
||||
userId: string,
|
||||
): Promise<boolean> {
|
||||
const row = await (
|
||||
await database.prepare(
|
||||
'SELECT 1 FROM mfa_recovery_codes WHERE user_id=? AND consumed_at IS NULL AND revoked_at IS NULL LIMIT 1',
|
||||
)
|
||||
).get(userId);
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
export async function isMfaReady(
|
||||
database: Database,
|
||||
userId: string,
|
||||
): Promise<boolean> {
|
||||
return (
|
||||
(await hasActiveTotpFactor(database, userId)) &&
|
||||
(await hasUnusedRecoveryCode(database, userId))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
||||
|
||||
export const MFA_TOTP_PARAMETERS = {
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
periodSeconds: 30,
|
||||
} as const;
|
||||
|
||||
const ENVELOPE_VERSION = 'mfa1';
|
||||
const KEY_BYTES = 32;
|
||||
const IV_BYTES = 12;
|
||||
const TAG_BYTES = 16;
|
||||
|
||||
export type MfaEncryptionKey = {
|
||||
id: string;
|
||||
key: Buffer;
|
||||
};
|
||||
|
||||
export class MfaCryptoError extends Error {
|
||||
constructor() {
|
||||
super('Unable to protect the MFA secret.');
|
||||
this.name = 'MfaCryptoError';
|
||||
}
|
||||
}
|
||||
|
||||
export type DecryptedTotpSecret = {
|
||||
secret: Buffer;
|
||||
keyId: string;
|
||||
needsRewrap: boolean;
|
||||
};
|
||||
|
||||
type TotpAadContext = {
|
||||
factorId: string;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
function isCanonicalBase64Url(value: string): boolean {
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(value)) return false;
|
||||
try {
|
||||
const decoded = Buffer.from(value, 'base64url');
|
||||
return (
|
||||
decoded.length === KEY_BYTES && decoded.toString('base64url') === value
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse the app-only keyring. No fallback or generated key is permitted. */
|
||||
export function parseMfaTotpEncryptionKeys(raw: string): MfaEncryptionKey[] {
|
||||
const entries = raw.split(',').map((entry) => entry.trim());
|
||||
if (
|
||||
entries.length < 1 ||
|
||||
entries.length > 3 ||
|
||||
entries.some((entry) => !entry)
|
||||
)
|
||||
throw new MfaCryptoError();
|
||||
|
||||
const ids = new Set<string>();
|
||||
const encodedKeys = new Set<string>();
|
||||
const keys: MfaEncryptionKey[] = [];
|
||||
for (const entry of entries) {
|
||||
const separator = entry.indexOf(':');
|
||||
const id = separator > 0 ? entry.slice(0, separator) : '';
|
||||
const encoded = separator > 0 ? entry.slice(separator + 1) : '';
|
||||
if (
|
||||
!/^[A-Za-z0-9_-]{1,64}$/.test(id) ||
|
||||
encoded.length !== 43 ||
|
||||
!isCanonicalBase64Url(encoded) ||
|
||||
ids.has(id) ||
|
||||
encodedKeys.has(encoded)
|
||||
)
|
||||
throw new MfaCryptoError();
|
||||
ids.add(id);
|
||||
encodedKeys.add(encoded);
|
||||
keys.push({ id, key: Buffer.from(encoded, 'base64url') });
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
export function getMfaTotpEncryptionKeys(): MfaEncryptionKey[] {
|
||||
const raw = env.MFA_TOTP_ENCRYPTION_KEYS?.trim();
|
||||
if (!raw) throw new MfaCryptoError();
|
||||
return parseMfaTotpEncryptionKeys(raw);
|
||||
}
|
||||
|
||||
function aadFor({ factorId, userId }: TotpAadContext): Buffer {
|
||||
if (!factorId || !userId) throw new MfaCryptoError();
|
||||
return Buffer.from(
|
||||
JSON.stringify({
|
||||
version: ENVELOPE_VERSION,
|
||||
factorId,
|
||||
userId,
|
||||
algorithm: MFA_TOTP_PARAMETERS.algorithm,
|
||||
digits: MFA_TOTP_PARAMETERS.digits,
|
||||
periodSeconds: MFA_TOTP_PARAMETERS.periodSeconds,
|
||||
}),
|
||||
'utf8',
|
||||
);
|
||||
}
|
||||
|
||||
function activeKey(keys: MfaEncryptionKey[]): MfaEncryptionKey {
|
||||
const key = keys[0];
|
||||
if (!key || key.key.length !== KEY_BYTES) throw new MfaCryptoError();
|
||||
return key;
|
||||
}
|
||||
|
||||
function envelopePart(value: Buffer): string {
|
||||
return value.toString('base64url');
|
||||
}
|
||||
|
||||
function decodeEnvelope(envelope: string): {
|
||||
keyId: string;
|
||||
iv: Buffer;
|
||||
ciphertext: Buffer;
|
||||
tag: Buffer;
|
||||
} {
|
||||
const parts = envelope.split('.');
|
||||
if (parts.length !== 5 || parts[0] !== ENVELOPE_VERSION)
|
||||
throw new MfaCryptoError();
|
||||
const [, keyId, encodedIv, encodedCiphertext, encodedTag] = parts;
|
||||
if (
|
||||
!/^[A-Za-z0-9_-]{1,64}$/.test(keyId) ||
|
||||
!encodedIv ||
|
||||
!encodedCiphertext ||
|
||||
!encodedTag
|
||||
)
|
||||
throw new MfaCryptoError();
|
||||
try {
|
||||
const iv = Buffer.from(encodedIv, 'base64url');
|
||||
const ciphertext = Buffer.from(encodedCiphertext, 'base64url');
|
||||
const tag = Buffer.from(encodedTag, 'base64url');
|
||||
if (
|
||||
iv.length !== IV_BYTES ||
|
||||
ciphertext.length !== 20 ||
|
||||
tag.length !== TAG_BYTES ||
|
||||
iv.toString('base64url') !== encodedIv ||
|
||||
ciphertext.toString('base64url') !== encodedCiphertext ||
|
||||
tag.toString('base64url') !== encodedTag
|
||||
)
|
||||
throw new MfaCryptoError();
|
||||
return { keyId, iv, ciphertext, tag };
|
||||
} catch {
|
||||
throw new MfaCryptoError();
|
||||
}
|
||||
}
|
||||
|
||||
function keyById(keys: MfaEncryptionKey[], id: string): MfaEncryptionKey {
|
||||
const key = keys.find((candidate) => candidate.id === id);
|
||||
if (!key || key.key.length !== KEY_BYTES) throw new MfaCryptoError();
|
||||
return key;
|
||||
}
|
||||
|
||||
export function encryptTotpSecret(
|
||||
context: TotpAadContext & { secret: Uint8Array },
|
||||
keys: MfaEncryptionKey[] = getMfaTotpEncryptionKeys(),
|
||||
): string {
|
||||
try {
|
||||
if (context.secret.length !== 20) throw new MfaCryptoError();
|
||||
const key = activeKey(keys);
|
||||
const iv = randomBytes(IV_BYTES);
|
||||
const cipher = createCipheriv('aes-256-gcm', key.key, iv);
|
||||
cipher.setAAD(aadFor(context));
|
||||
const ciphertext = Buffer.concat([
|
||||
cipher.update(context.secret),
|
||||
cipher.final(),
|
||||
]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return [
|
||||
ENVELOPE_VERSION,
|
||||
key.id,
|
||||
envelopePart(iv),
|
||||
envelopePart(ciphertext),
|
||||
envelopePart(tag),
|
||||
].join('.');
|
||||
} catch (error) {
|
||||
if (error instanceof MfaCryptoError) throw error;
|
||||
throw new MfaCryptoError();
|
||||
}
|
||||
}
|
||||
|
||||
export function decryptTotpSecret(
|
||||
context: TotpAadContext & { envelope: string },
|
||||
keys: MfaEncryptionKey[] = getMfaTotpEncryptionKeys(),
|
||||
): DecryptedTotpSecret {
|
||||
try {
|
||||
const envelope = decodeEnvelope(context.envelope);
|
||||
const key = keyById(keys, envelope.keyId);
|
||||
const decipher = createDecipheriv('aes-256-gcm', key.key, envelope.iv);
|
||||
decipher.setAAD(aadFor(context));
|
||||
decipher.setAuthTag(envelope.tag);
|
||||
const secret = Buffer.concat([
|
||||
decipher.update(envelope.ciphertext),
|
||||
decipher.final(),
|
||||
]);
|
||||
if (secret.length !== 20) throw new MfaCryptoError();
|
||||
return {
|
||||
secret,
|
||||
keyId: key.id,
|
||||
needsRewrap: key.id !== activeKey(keys).id,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof MfaCryptoError) throw error;
|
||||
throw new MfaCryptoError();
|
||||
}
|
||||
}
|
||||
|
||||
export function rewrapTotpSecret(
|
||||
context: TotpAadContext & { envelope: string },
|
||||
keys: MfaEncryptionKey[] = getMfaTotpEncryptionKeys(),
|
||||
): { envelope: string; previousKeyId: string; activeKeyId: string } {
|
||||
const decrypted = decryptTotpSecret(context, keys);
|
||||
const active = activeKey(keys);
|
||||
return {
|
||||
envelope: encryptTotpSecret(
|
||||
{
|
||||
factorId: context.factorId,
|
||||
userId: context.userId,
|
||||
secret: decrypted.secret,
|
||||
},
|
||||
keys,
|
||||
),
|
||||
previousKeyId: decrypted.keyId,
|
||||
activeKeyId: active.id,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { connect, type Database } from '@tursodatabase/database';
|
||||
import { Secret } from 'otpauth';
|
||||
|
||||
const { keyA, keyB } = vi.hoisted(() => ({
|
||||
keyA: Buffer.alloc(32, 0x11).toString('base64url'),
|
||||
keyB: Buffer.alloc(32, 0x22).toString('base64url'),
|
||||
}));
|
||||
|
||||
vi.mock('$env/dynamic/private', () => ({
|
||||
env: {
|
||||
MFA_TOTP_ENCRYPTION_KEYS: `active:${keyA},retained:${keyB}`,
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
decryptTotpSecret,
|
||||
encryptTotpSecret,
|
||||
parseMfaTotpEncryptionKeys,
|
||||
rewrapTotpSecret,
|
||||
MfaCryptoError,
|
||||
} from './mfa-crypto';
|
||||
import {
|
||||
beginTotpEnrollment,
|
||||
generateTotpCode,
|
||||
verifyTotpFactor,
|
||||
} from './mfa-totp';
|
||||
import {
|
||||
consumeRecoveryCode,
|
||||
formatRecoveryCode,
|
||||
issueRecoveryCodes,
|
||||
normalizeRecoveryCode,
|
||||
} from './mfa-recovery';
|
||||
import {
|
||||
consumeAuthenticationTransaction,
|
||||
createAuthenticationTransaction,
|
||||
getAuthenticationTransaction,
|
||||
recordAuthenticationAttempt,
|
||||
validateAuthenticationReturnPath,
|
||||
} from './authentication-transactions';
|
||||
|
||||
const now = new Date('2026-08-07T12:00:00.000Z');
|
||||
|
||||
function cookieJar() {
|
||||
const values = new Map<string, string>();
|
||||
return {
|
||||
values,
|
||||
get: (name: string) => values.get(name),
|
||||
set: vi.fn((name: string, value: string) => values.set(name, value)),
|
||||
delete: vi.fn((name: string) => values.delete(name)),
|
||||
};
|
||||
}
|
||||
|
||||
async function mfaDatabase(userId = 'user-1'): Promise<Database> {
|
||||
const database = await connect(':memory:');
|
||||
await database.exec(`
|
||||
CREATE TABLE users (id TEXT PRIMARY KEY);
|
||||
INSERT INTO users(id) VALUES ('${userId}');
|
||||
CREATE TABLE mfa_totp_factors (
|
||||
id TEXT PRIMARY KEY, user_id TEXT NOT NULL, state TEXT NOT NULL,
|
||||
encrypted_secret TEXT NOT NULL, encryption_key_id TEXT NOT NULL,
|
||||
issuer TEXT NOT NULL, account_label TEXT NOT NULL, algorithm TEXT NOT NULL,
|
||||
digits INTEGER NOT NULL, period_seconds INTEGER NOT NULL,
|
||||
last_counter INTEGER, failed_attempts INTEGER NOT NULL DEFAULT 0,
|
||||
locked_until TEXT, expires_at TEXT, created_at TEXT NOT NULL,
|
||||
activated_at TEXT, revoked_at TEXT
|
||||
);
|
||||
CREATE TABLE mfa_recovery_codes (
|
||||
id TEXT PRIMARY KEY, user_id TEXT NOT NULL, code_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL, consumed_at TEXT, revoked_at TEXT
|
||||
);
|
||||
`);
|
||||
return database;
|
||||
}
|
||||
|
||||
async function transactionDatabase(): Promise<Database> {
|
||||
const database = await connect(':memory:');
|
||||
await database.exec(`
|
||||
CREATE TABLE authentication_transactions (
|
||||
id TEXT PRIMARY KEY, token_hash TEXT NOT NULL UNIQUE, user_id TEXT NOT NULL,
|
||||
provider TEXT, kind TEXT NOT NULL, return_path TEXT NOT NULL, required_aal INTEGER NOT NULL,
|
||||
attempt_count INTEGER NOT NULL, expires_at TEXT NOT NULL, consumed_at TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
return database;
|
||||
}
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe('MFA encryption foundation', () => {
|
||||
it('rejects malformed keyrings and reports rotation without exposing keys', () => {
|
||||
expect(() => parseMfaTotpEncryptionKeys('')).toThrow(MfaCryptoError);
|
||||
expect(() => parseMfaTotpEncryptionKeys(`one:${keyA},two:${keyA}`)).toThrow(
|
||||
MfaCryptoError,
|
||||
);
|
||||
expect(() =>
|
||||
parseMfaTotpEncryptionKeys(`one:${keyA}=,two:${keyB}`),
|
||||
).toThrow(MfaCryptoError);
|
||||
|
||||
const secret = Buffer.alloc(20, 0x33);
|
||||
const oldKeys = parseMfaTotpEncryptionKeys(`old:${keyB},active:${keyA}`);
|
||||
const envelope = encryptTotpSecret(
|
||||
{ factorId: 'factor-1', userId: 'user-1', secret },
|
||||
oldKeys,
|
||||
);
|
||||
const decrypted = decryptTotpSecret(
|
||||
{ factorId: 'factor-1', userId: 'user-1', envelope },
|
||||
parseMfaTotpEncryptionKeys(`active:${keyA},old:${keyB}`),
|
||||
);
|
||||
expect(decrypted.secret).toEqual(secret);
|
||||
expect(decrypted.needsRewrap).toBe(true);
|
||||
const rewrapped = rewrapTotpSecret(
|
||||
{ factorId: 'factor-1', userId: 'user-1', envelope },
|
||||
parseMfaTotpEncryptionKeys(`active:${keyA},old:${keyB}`),
|
||||
);
|
||||
expect(rewrapped.previousKeyId).toBe('old');
|
||||
expect(rewrapped.activeKeyId).toBe('active');
|
||||
expect(() =>
|
||||
decryptTotpSecret(
|
||||
{ factorId: 'factor-2', userId: 'user-1', envelope },
|
||||
oldKeys,
|
||||
),
|
||||
).toThrow(MfaCryptoError);
|
||||
expect(() =>
|
||||
decryptTotpSecret(
|
||||
{ factorId: 'factor-1', userId: 'user-1', envelope: `${envelope}x` },
|
||||
oldKeys,
|
||||
),
|
||||
).toThrow(MfaCryptoError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TOTP factors', () => {
|
||||
let database: Database;
|
||||
|
||||
beforeEach(async () => {
|
||||
database = await mfaDatabase();
|
||||
});
|
||||
|
||||
afterEach(async () => database.close());
|
||||
|
||||
it('uses the one-period window and atomically rejects counter replay', async () => {
|
||||
const enrollment = await beginTotpEnrollment(database, {
|
||||
userId: 'user-1',
|
||||
now,
|
||||
});
|
||||
const secret = Secret.fromBase32(enrollment.secretBase32).bytes;
|
||||
const nextPeriodCode = generateTotpCode(secret, now.getTime() + 30_000);
|
||||
const verified = await verifyTotpFactor(database, {
|
||||
userId: 'user-1',
|
||||
factorId: enrollment.factorId,
|
||||
token: nextPeriodCode,
|
||||
now,
|
||||
});
|
||||
expect(verified.status).toBe('verified');
|
||||
const replay = await verifyTotpFactor(database, {
|
||||
userId: 'user-1',
|
||||
factorId: enrollment.factorId,
|
||||
token: nextPeriodCode,
|
||||
now,
|
||||
});
|
||||
expect(replay.status).toBe('replayed');
|
||||
});
|
||||
|
||||
it('locks after five failures and expires pending enrollment', async () => {
|
||||
const enrollment = await beginTotpEnrollment(database, {
|
||||
userId: 'user-1',
|
||||
now,
|
||||
});
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
await verifyTotpFactor(database, {
|
||||
userId: 'user-1',
|
||||
factorId: enrollment.factorId,
|
||||
token: '000000',
|
||||
now,
|
||||
});
|
||||
}
|
||||
const locked = await verifyTotpFactor(database, {
|
||||
userId: 'user-1',
|
||||
factorId: enrollment.factorId,
|
||||
token: '000000',
|
||||
now,
|
||||
});
|
||||
expect(locked.status).toBe('locked');
|
||||
|
||||
const expired = await verifyTotpFactor(database, {
|
||||
userId: 'user-1',
|
||||
factorId: enrollment.factorId,
|
||||
token: '000000',
|
||||
now: new Date(now.getTime() + 11 * 60_000),
|
||||
});
|
||||
expect(expired.status).toBe('expired');
|
||||
});
|
||||
|
||||
it('rewraps a verified factor under the active encryption key atomically', async () => {
|
||||
const secret = Buffer.alloc(20, 0x44);
|
||||
const factorId = 'factor-old-key';
|
||||
const oldEnvelope = encryptTotpSecret(
|
||||
{ factorId, userId: 'user-1', secret },
|
||||
parseMfaTotpEncryptionKeys(`retained:${keyB},active:${keyA}`),
|
||||
);
|
||||
await (
|
||||
await database.prepare(
|
||||
`INSERT INTO mfa_totp_factors(
|
||||
id,user_id,state,encrypted_secret,encryption_key_id,issuer,account_label,
|
||||
algorithm,digits,period_seconds,failed_attempts,created_at,activated_at
|
||||
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
)
|
||||
).run(
|
||||
factorId,
|
||||
'user-1',
|
||||
'active',
|
||||
oldEnvelope,
|
||||
'retained',
|
||||
'SHD.build',
|
||||
'user-1',
|
||||
'SHA1',
|
||||
6,
|
||||
30,
|
||||
0,
|
||||
now.toISOString(),
|
||||
now.toISOString(),
|
||||
);
|
||||
const result = await verifyTotpFactor(database, {
|
||||
userId: 'user-1',
|
||||
factorId,
|
||||
token: generateTotpCode(secret, now.getTime()),
|
||||
now,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
status: 'verified',
|
||||
needsRewrap: true,
|
||||
keyId: 'retained',
|
||||
});
|
||||
const row = await (
|
||||
await database.prepare(
|
||||
'SELECT encrypted_secret,encryption_key_id FROM mfa_totp_factors WHERE id=?',
|
||||
)
|
||||
).get(factorId);
|
||||
expect(row.encryption_key_id).toBe('active');
|
||||
expect(row.encrypted_secret).not.toBe(oldEnvelope);
|
||||
expect(
|
||||
decryptTotpSecret(
|
||||
{
|
||||
factorId,
|
||||
userId: 'user-1',
|
||||
envelope: String(row.encrypted_secret),
|
||||
},
|
||||
parseMfaTotpEncryptionKeys(`active:${keyA},retained:${keyB}`),
|
||||
).secret,
|
||||
).toEqual(secret);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recovery codes', () => {
|
||||
let database: Database;
|
||||
|
||||
beforeEach(async () => {
|
||||
database = await mfaDatabase();
|
||||
});
|
||||
|
||||
afterEach(async () => database.close());
|
||||
|
||||
it('issues ten formatted codes and consumes one atomically', async () => {
|
||||
const issued = await issueRecoveryCodes(database, {
|
||||
userId: 'user-1',
|
||||
now,
|
||||
});
|
||||
expect(issued.codes).toHaveLength(10);
|
||||
expect(issued.codes[0]).toMatch(/^[0-9A-F]{4}(?:-[0-9A-F]{4}){7}$/);
|
||||
expect(normalizeRecoveryCode(issued.codes[0])).toHaveLength(32);
|
||||
expect(formatRecoveryCode(normalizeRecoveryCode(issued.codes[0])!)).toBe(
|
||||
issued.codes[0],
|
||||
);
|
||||
expect(
|
||||
await consumeRecoveryCode(database, {
|
||||
userId: 'user-1',
|
||||
code: issued.codes[0],
|
||||
now,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
await consumeRecoveryCode(database, {
|
||||
userId: 'user-1',
|
||||
code: issued.codes[0],
|
||||
now,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('revokes unused codes when regenerated', async () => {
|
||||
await issueRecoveryCodes(database, { userId: 'user-1', now });
|
||||
await issueRecoveryCodes(database, {
|
||||
userId: 'user-1',
|
||||
now: new Date(now.getTime() + 1_000),
|
||||
});
|
||||
const revoked = await (
|
||||
await database.prepare(
|
||||
'SELECT COUNT(*) AS count FROM mfa_recovery_codes WHERE revoked_at IS NOT NULL',
|
||||
)
|
||||
).get();
|
||||
expect(Number(revoked.count)).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('authentication transactions', () => {
|
||||
let database: Database;
|
||||
|
||||
beforeEach(async () => {
|
||||
database = await transactionDatabase();
|
||||
});
|
||||
|
||||
afterEach(async () => database.close());
|
||||
|
||||
it('enforces local return paths, five attempts, and one-time consumption', async () => {
|
||||
expect(validateAuthenticationReturnPath('/builder?tab=weapons')).toBe(
|
||||
'/builder?tab=weapons',
|
||||
);
|
||||
expect(
|
||||
validateAuthenticationReturnPath('https://evil.example/'),
|
||||
).toBeNull();
|
||||
expect(validateAuthenticationReturnPath('//evil.example')).toBeNull();
|
||||
expect(validateAuthenticationReturnPath('/%2F%2Fevil.example')).toBeNull();
|
||||
|
||||
const cookies = cookieJar();
|
||||
const transaction = await createAuthenticationTransaction(
|
||||
cookies as never,
|
||||
database,
|
||||
{
|
||||
userId: 'user-1',
|
||||
kind: 'login',
|
||||
provider: 'password',
|
||||
returnPath: '/builder',
|
||||
now,
|
||||
},
|
||||
);
|
||||
expect(cookies.set).toHaveBeenCalledWith(
|
||||
'shd_auth_tx',
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
path: '/auth/two-factor',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: 300,
|
||||
}),
|
||||
);
|
||||
expect(transaction.provider).toBe('password');
|
||||
const stored = await (
|
||||
await database.prepare(
|
||||
'SELECT token_hash FROM authentication_transactions',
|
||||
)
|
||||
).get();
|
||||
expect(String(stored.token_hash)).not.toBe(
|
||||
cookies.values.get('shd_auth_tx'),
|
||||
);
|
||||
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
expect(
|
||||
(await recordAuthenticationAttempt(cookies as never, database, now))
|
||||
.accepted,
|
||||
).toBe(true);
|
||||
}
|
||||
expect(
|
||||
await getAuthenticationTransaction(cookies as never, database, now),
|
||||
).toBeNull();
|
||||
|
||||
const secondCookies = cookieJar();
|
||||
const second = await createAuthenticationTransaction(
|
||||
secondCookies as never,
|
||||
database,
|
||||
{
|
||||
userId: 'user-1',
|
||||
kind: 'step_up',
|
||||
returnPath: '/account/security',
|
||||
now,
|
||||
},
|
||||
);
|
||||
const consumed = await consumeAuthenticationTransaction(
|
||||
secondCookies as never,
|
||||
database,
|
||||
now,
|
||||
);
|
||||
expect(consumed).toMatchObject({
|
||||
id: second.id,
|
||||
kind: 'step_up',
|
||||
requiredAal: 2,
|
||||
});
|
||||
expect(
|
||||
await consumeAuthenticationTransaction(
|
||||
secondCookies as never,
|
||||
database,
|
||||
now,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import type { Database } from '@tursodatabase/database';
|
||||
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
||||
|
||||
export const RECOVERY_CODE_COUNT = 10;
|
||||
export const RECOVERY_CODE_BYTES = 16;
|
||||
const RECOVERY_CODE_DOMAIN = 'shd.build:mfa-recovery-code:v1';
|
||||
|
||||
export type RecoveryDatabase = Pick<Database, 'prepare' | 'transactionAsync'>;
|
||||
export type RecoveryTransaction = Pick<Database, 'prepare'>;
|
||||
|
||||
export class RecoveryCodeError extends Error {
|
||||
constructor(message = 'Unable to process recovery codes.') {
|
||||
super(message);
|
||||
this.name = 'RecoveryCodeError';
|
||||
}
|
||||
}
|
||||
|
||||
export type IssuedRecoveryCodes = {
|
||||
codes: string[];
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
function databaseOrDefault(
|
||||
database?: RecoveryDatabase,
|
||||
): Promise<RecoveryDatabase> {
|
||||
return database
|
||||
? Promise.resolve(database)
|
||||
: (getDatabase() as Promise<RecoveryDatabase>);
|
||||
}
|
||||
|
||||
function timestamp(value: Date | string | number | undefined): string {
|
||||
const date =
|
||||
value instanceof Date ? new Date(value) : new Date(value ?? Date.now());
|
||||
if (!Number.isFinite(date.getTime())) throw new RecoveryCodeError();
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
/** Return the canonical code used for hashing, or null for malformed input. */
|
||||
export function normalizeRecoveryCode(input: string): string | null {
|
||||
if (typeof input !== 'string') return null;
|
||||
const normalized = input
|
||||
.normalize('NFKC')
|
||||
.replace(/[\s-]+/gu, '')
|
||||
.toUpperCase();
|
||||
return /^[0-9A-F]{32}$/.test(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
/** Add display separators without changing the canonical validation format. */
|
||||
export function formatRecoveryCode(canonicalCode: string): string {
|
||||
const normalized = normalizeRecoveryCode(canonicalCode);
|
||||
if (!normalized) throw new RecoveryCodeError();
|
||||
return normalized.match(/.{1,4}/g)?.join('-') ?? normalized;
|
||||
}
|
||||
|
||||
export function generateRecoveryCode(): { canonical: string; display: string } {
|
||||
const canonical = randomBytes(RECOVERY_CODE_BYTES)
|
||||
.toString('hex')
|
||||
.toUpperCase();
|
||||
return { canonical, display: formatRecoveryCode(canonical) };
|
||||
}
|
||||
|
||||
export function hashRecoveryCode(userId: string, code: string): string {
|
||||
const normalized = normalizeRecoveryCode(code);
|
||||
if (!userId || !normalized) throw new RecoveryCodeError();
|
||||
return createHash('sha256')
|
||||
.update(RECOVERY_CODE_DOMAIN, 'utf8')
|
||||
.update('\0', 'utf8')
|
||||
.update(userId, 'utf8')
|
||||
.update('\0', 'utf8')
|
||||
.update(normalized, 'utf8')
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
export async function issueRecoveryCodesInTransaction(
|
||||
transaction: RecoveryTransaction,
|
||||
options: { userId: string; now?: Date | string | number },
|
||||
): Promise<IssuedRecoveryCodes> {
|
||||
if (!options.userId) throw new RecoveryCodeError();
|
||||
const createdAt = timestamp(options.now);
|
||||
const generated = Array.from(
|
||||
{ length: RECOVERY_CODE_COUNT },
|
||||
generateRecoveryCode,
|
||||
);
|
||||
await (
|
||||
await transaction.prepare(
|
||||
'UPDATE mfa_recovery_codes SET revoked_at=? WHERE user_id=? AND consumed_at IS NULL AND revoked_at IS NULL',
|
||||
)
|
||||
).run(createdAt, options.userId);
|
||||
for (const code of generated) {
|
||||
await (
|
||||
await transaction.prepare(
|
||||
'INSERT INTO mfa_recovery_codes(id,user_id,code_hash,created_at) VALUES(?,?,?,?)',
|
||||
)
|
||||
).run(
|
||||
randomUUID(),
|
||||
options.userId,
|
||||
hashRecoveryCode(options.userId, code.canonical),
|
||||
createdAt,
|
||||
);
|
||||
}
|
||||
return { codes: generated.map((code) => code.display), createdAt };
|
||||
}
|
||||
|
||||
export async function issueRecoveryCodes(
|
||||
databaseInput: RecoveryDatabase | undefined,
|
||||
options: { userId: string; now?: Date | string | number },
|
||||
): Promise<IssuedRecoveryCodes> {
|
||||
if (!options.userId) throw new RecoveryCodeError();
|
||||
const database = await databaseOrDefault(databaseInput);
|
||||
const issued = database.transactionAsync(async (transaction) => {
|
||||
return issueRecoveryCodesInTransaction(transaction, options);
|
||||
});
|
||||
return await issued.immediate();
|
||||
}
|
||||
|
||||
export const regenerateRecoveryCodes = issueRecoveryCodes;
|
||||
|
||||
export async function consumeRecoveryCode(
|
||||
databaseInput: RecoveryDatabase | undefined,
|
||||
options: { userId: string; code: string; now?: Date | string | number },
|
||||
): Promise<boolean> {
|
||||
const normalized = normalizeRecoveryCode(options.code);
|
||||
if (!normalized || !options.userId) return false;
|
||||
const database = await databaseOrDefault(databaseInput);
|
||||
const consumed = database.transactionAsync((transaction) =>
|
||||
consumeRecoveryCodeInTransaction(transaction, {
|
||||
...options,
|
||||
code: normalized,
|
||||
}),
|
||||
);
|
||||
return await consumed.immediate();
|
||||
}
|
||||
|
||||
/** Consume one recovery code using the caller's transaction. */
|
||||
export async function consumeRecoveryCodeInTransaction(
|
||||
transaction: RecoveryTransaction,
|
||||
options: { userId: string; code: string; now?: Date | string | number },
|
||||
): Promise<boolean> {
|
||||
const normalized = normalizeRecoveryCode(options.code);
|
||||
if (!normalized || !options.userId) return false;
|
||||
const consumedAt = timestamp(options.now);
|
||||
const result = await (
|
||||
await transaction.prepare(
|
||||
`UPDATE mfa_recovery_codes
|
||||
SET consumed_at=?
|
||||
WHERE user_id=? AND code_hash=? AND consumed_at IS NULL AND revoked_at IS NULL`,
|
||||
)
|
||||
).run(
|
||||
consumedAt,
|
||||
options.userId,
|
||||
hashRecoveryCode(options.userId, normalized),
|
||||
);
|
||||
return result.changes === 1;
|
||||
}
|
||||
|
||||
export const useRecoveryCode = consumeRecoveryCode;
|
||||
@@ -0,0 +1,416 @@
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import type { Database } from '@tursodatabase/database';
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
import { Secret, TOTP } from 'otpauth';
|
||||
import {
|
||||
decryptTotpSecret,
|
||||
encryptTotpSecret,
|
||||
getMfaTotpEncryptionKeys,
|
||||
MFA_TOTP_PARAMETERS,
|
||||
rewrapTotpSecret,
|
||||
} from './mfa-crypto';
|
||||
|
||||
export const TOTP_ISSUER = 'SHD.build';
|
||||
export const TOTP_SECRET_BYTES = 20;
|
||||
export const TOTP_WINDOW = 1;
|
||||
export const TOTP_PENDING_SECONDS = 10 * 60;
|
||||
export const TOTP_FAILURE_LIMIT = 5;
|
||||
export const TOTP_LOCK_SECONDS = 5 * 60;
|
||||
|
||||
export type TotpDatabase = Pick<Database, 'prepare' | 'transactionAsync'>;
|
||||
export type TotpTransaction = Pick<Database, 'prepare'>;
|
||||
|
||||
export class TotpServiceError extends Error {
|
||||
constructor(message = 'Unable to process the TOTP factor.') {
|
||||
super(message);
|
||||
this.name = 'TotpServiceError';
|
||||
}
|
||||
}
|
||||
|
||||
export type TotpEnrollment = {
|
||||
factorId: string;
|
||||
secretBase32: string;
|
||||
provisioningUri: string;
|
||||
expiresAt: string;
|
||||
replacedFactorId?: string;
|
||||
};
|
||||
|
||||
export type TotpEnrollmentOptions = {
|
||||
userId: string;
|
||||
accountLabel?: string;
|
||||
now?: Date | string | number;
|
||||
replaceActive?: boolean;
|
||||
};
|
||||
|
||||
export type TotpVerificationResult =
|
||||
| {
|
||||
status: 'verified';
|
||||
counter: number;
|
||||
needsRewrap: boolean;
|
||||
keyId: string;
|
||||
}
|
||||
| {
|
||||
status: 'invalid' | 'replayed' | 'expired';
|
||||
lockedUntil: string | null;
|
||||
}
|
||||
| {
|
||||
status: 'locked';
|
||||
lockedUntil: string;
|
||||
};
|
||||
|
||||
export type TotpVerificationOptions = {
|
||||
userId: string;
|
||||
factorId: string;
|
||||
token: string;
|
||||
now?: Date | string | number;
|
||||
};
|
||||
|
||||
type TotpFactorRow = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
state: 'pending' | 'active' | 'revoked';
|
||||
encrypted_secret: string;
|
||||
encryption_key_id: string;
|
||||
issuer: string;
|
||||
account_label: string;
|
||||
last_counter: number | null;
|
||||
failed_attempts: number;
|
||||
locked_until: string | null;
|
||||
expires_at: string | null;
|
||||
};
|
||||
|
||||
function databaseOrDefault(database?: TotpDatabase): Promise<TotpDatabase> {
|
||||
return database
|
||||
? Promise.resolve(database)
|
||||
: (getDatabase() as Promise<TotpDatabase>);
|
||||
}
|
||||
|
||||
function asTimestamp(value: Date | string | number | undefined): Date {
|
||||
const result =
|
||||
value instanceof Date
|
||||
? new Date(value.getTime())
|
||||
: new Date(value ?? Date.now());
|
||||
if (!Number.isFinite(result.getTime())) throw new TotpServiceError();
|
||||
return result;
|
||||
}
|
||||
|
||||
function toSecret(secret: Uint8Array): Secret {
|
||||
if (secret.length !== TOTP_SECRET_BYTES) throw new TotpServiceError();
|
||||
const bytes = Uint8Array.from(secret);
|
||||
return new Secret({ buffer: bytes.buffer as ArrayBuffer });
|
||||
}
|
||||
|
||||
function totpFor(secret: Uint8Array, label: string): TOTP {
|
||||
return new TOTP({
|
||||
issuer: TOTP_ISSUER,
|
||||
label,
|
||||
issuerInLabel: true,
|
||||
secret: toSecret(secret),
|
||||
algorithm: MFA_TOTP_PARAMETERS.algorithm,
|
||||
digits: MFA_TOTP_PARAMETERS.digits,
|
||||
period: MFA_TOTP_PARAMETERS.periodSeconds,
|
||||
});
|
||||
}
|
||||
|
||||
export function generateTotpSecret(): Buffer {
|
||||
return randomBytes(TOTP_SECRET_BYTES);
|
||||
}
|
||||
|
||||
export function generateTotpCode(
|
||||
secret: Uint8Array,
|
||||
timestamp = Date.now(),
|
||||
): string {
|
||||
return totpFor(secret, TOTP_ISSUER).generate({ timestamp });
|
||||
}
|
||||
|
||||
export function validateTotpCode(
|
||||
secret: Uint8Array,
|
||||
token: string,
|
||||
timestamp = Date.now(),
|
||||
): number | null {
|
||||
if (!/^\d{6}$/.test(token)) return null;
|
||||
return totpFor(secret, TOTP_ISSUER).validate({
|
||||
token,
|
||||
timestamp,
|
||||
window: TOTP_WINDOW,
|
||||
});
|
||||
}
|
||||
|
||||
export function totpCounter(timestamp = Date.now()): number {
|
||||
return TOTP.counter({
|
||||
period: MFA_TOTP_PARAMETERS.periodSeconds,
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
function provisioningUri(secret: Uint8Array, label: string): string {
|
||||
return totpFor(secret, label).toString();
|
||||
}
|
||||
|
||||
export async function beginTotpEnrollmentInTransaction(
|
||||
transaction: TotpTransaction,
|
||||
options: TotpEnrollmentOptions,
|
||||
): Promise<TotpEnrollment> {
|
||||
if (!options.userId || options.userId.length > 200)
|
||||
throw new TotpServiceError();
|
||||
const now = asTimestamp(options.now);
|
||||
const nowIso = now.toISOString();
|
||||
const expiresAt = new Date(
|
||||
now.getTime() + TOTP_PENDING_SECONDS * 1000,
|
||||
).toISOString();
|
||||
const factorId = randomUUID();
|
||||
const accountLabel = (options.accountLabel ?? options.userId).trim();
|
||||
if (!accountLabel || accountLabel.length > 200) throw new TotpServiceError();
|
||||
const secret = generateTotpSecret();
|
||||
const keys = getMfaTotpEncryptionKeys();
|
||||
const encryptedSecret = encryptTotpSecret(
|
||||
{ factorId, userId: options.userId, secret },
|
||||
keys,
|
||||
);
|
||||
|
||||
const current = (await (
|
||||
await transaction.prepare(
|
||||
"SELECT id,state,expires_at FROM mfa_totp_factors WHERE user_id=? AND state IN ('pending','active')",
|
||||
)
|
||||
).all(options.userId)) as Array<{
|
||||
id: string;
|
||||
state: 'pending' | 'active';
|
||||
expires_at: string | null;
|
||||
}>;
|
||||
const pending = current.find((row) => row.state === 'pending');
|
||||
if (pending && (!pending.expires_at || pending.expires_at <= nowIso)) {
|
||||
await (
|
||||
await transaction.prepare(
|
||||
"UPDATE mfa_totp_factors SET state='revoked',revoked_at=? WHERE id=? AND user_id=? AND state='pending'",
|
||||
)
|
||||
).run(nowIso, pending.id, options.userId);
|
||||
} else if (pending) {
|
||||
throw new TotpServiceError();
|
||||
}
|
||||
const active = current.find((row) => row.state === 'active');
|
||||
if (active && !options.replaceActive) throw new TotpServiceError();
|
||||
|
||||
await (
|
||||
await transaction.prepare(
|
||||
`INSERT INTO mfa_totp_factors(
|
||||
id,user_id,state,encrypted_secret,encryption_key_id,issuer,account_label,
|
||||
algorithm,digits,period_seconds,failed_attempts,expires_at,created_at
|
||||
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
)
|
||||
).run(
|
||||
factorId,
|
||||
options.userId,
|
||||
'pending',
|
||||
encryptedSecret,
|
||||
keys[0].id,
|
||||
TOTP_ISSUER,
|
||||
accountLabel,
|
||||
MFA_TOTP_PARAMETERS.algorithm,
|
||||
MFA_TOTP_PARAMETERS.digits,
|
||||
MFA_TOTP_PARAMETERS.periodSeconds,
|
||||
0,
|
||||
expiresAt,
|
||||
nowIso,
|
||||
);
|
||||
|
||||
return {
|
||||
factorId,
|
||||
secretBase32: toSecret(secret).base32,
|
||||
provisioningUri: provisioningUri(secret, accountLabel),
|
||||
expiresAt,
|
||||
...(active ? { replacedFactorId: active.id } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function beginTotpEnrollment(
|
||||
databaseInput: TotpDatabase | undefined,
|
||||
options: TotpEnrollmentOptions,
|
||||
): Promise<TotpEnrollment> {
|
||||
const database = await databaseOrDefault(databaseInput);
|
||||
const insert = database.transactionAsync(
|
||||
async (transaction) =>
|
||||
await beginTotpEnrollmentInTransaction(transaction, options),
|
||||
);
|
||||
return await insert.immediate();
|
||||
}
|
||||
|
||||
export const startTotpEnrollment = beginTotpEnrollment;
|
||||
|
||||
function lockedUntilDate(row: TotpFactorRow, now: Date): string | null {
|
||||
if (!row.locked_until) return null;
|
||||
return new Date(row.locked_until) > now ? row.locked_until : null;
|
||||
}
|
||||
|
||||
export async function verifyTotpFactorInTransaction(
|
||||
transaction: TotpTransaction,
|
||||
options: TotpVerificationOptions,
|
||||
pendingOnly = false,
|
||||
activatePending = true,
|
||||
): Promise<TotpVerificationResult> {
|
||||
const now = asTimestamp(options.now);
|
||||
const nowIso = now.toISOString();
|
||||
const lockUntil = new Date(
|
||||
now.getTime() + TOTP_LOCK_SECONDS * 1000,
|
||||
).toISOString();
|
||||
|
||||
const row = (await (
|
||||
await transaction.prepare(
|
||||
'SELECT id,user_id,state,encrypted_secret,encryption_key_id,issuer,account_label,last_counter,failed_attempts,locked_until,expires_at FROM mfa_totp_factors WHERE id=? AND user_id=? LIMIT 1',
|
||||
)
|
||||
).get(options.factorId, options.userId)) as TotpFactorRow | undefined;
|
||||
if (
|
||||
!row ||
|
||||
row.state === 'revoked' ||
|
||||
(pendingOnly && row.state !== 'pending')
|
||||
)
|
||||
return { status: 'invalid', lockedUntil: null } as const;
|
||||
if (
|
||||
row.state === 'pending' &&
|
||||
(!row.expires_at || row.expires_at <= nowIso)
|
||||
) {
|
||||
await (
|
||||
await transaction.prepare(
|
||||
"UPDATE mfa_totp_factors SET state='revoked',revoked_at=? WHERE id=? AND state='pending'",
|
||||
)
|
||||
).run(nowIso, row.id);
|
||||
return { status: 'expired', lockedUntil: null } as const;
|
||||
}
|
||||
const existingLock = lockedUntilDate(row, now);
|
||||
if (existingLock)
|
||||
return { status: 'locked', lockedUntil: existingLock } as const;
|
||||
|
||||
let decrypted;
|
||||
try {
|
||||
decrypted = decryptTotpSecret(
|
||||
{
|
||||
factorId: row.id,
|
||||
userId: row.user_id,
|
||||
envelope: row.encrypted_secret,
|
||||
},
|
||||
getMfaTotpEncryptionKeys(),
|
||||
);
|
||||
} catch {
|
||||
throw new TotpServiceError();
|
||||
}
|
||||
const delta = validateTotpCode(
|
||||
decrypted.secret,
|
||||
options.token,
|
||||
now.getTime(),
|
||||
);
|
||||
const currentCounter = totpCounter(now.getTime());
|
||||
const matchedCounter = delta === null ? null : currentCounter + delta;
|
||||
if (matchedCounter !== null) {
|
||||
const update =
|
||||
pendingOnly && !activatePending
|
||||
? `UPDATE mfa_totp_factors
|
||||
SET last_counter=?,failed_attempts=0,locked_until=NULL
|
||||
WHERE id=? AND user_id=? AND state='pending'
|
||||
AND (last_counter IS NULL OR last_counter<?)
|
||||
AND (locked_until IS NULL OR locked_until<=?)`
|
||||
: `UPDATE mfa_totp_factors
|
||||
SET last_counter=?,failed_attempts=0,locked_until=NULL,
|
||||
state=CASE WHEN state='pending' THEN 'active' ELSE state END,
|
||||
expires_at=CASE WHEN state='pending' THEN NULL ELSE expires_at END,
|
||||
activated_at=CASE WHEN state='pending' THEN ? ELSE activated_at END
|
||||
WHERE id=? AND user_id=? AND state IN ('pending','active')
|
||||
AND (last_counter IS NULL OR last_counter<?)
|
||||
AND (locked_until IS NULL OR locked_until<=?)`;
|
||||
const updated = await (
|
||||
await transaction.prepare(update)
|
||||
).run(
|
||||
...(pendingOnly && !activatePending
|
||||
? [matchedCounter, row.id, options.userId, matchedCounter, nowIso]
|
||||
: [
|
||||
matchedCounter,
|
||||
nowIso,
|
||||
row.id,
|
||||
options.userId,
|
||||
matchedCounter,
|
||||
nowIso,
|
||||
]),
|
||||
);
|
||||
if (updated.changes === 1) {
|
||||
if (decrypted.needsRewrap) {
|
||||
const rewrapped = rewrapTotpSecret(
|
||||
{
|
||||
factorId: row.id,
|
||||
userId: row.user_id,
|
||||
envelope: row.encrypted_secret,
|
||||
},
|
||||
getMfaTotpEncryptionKeys(),
|
||||
);
|
||||
const rewrappedRow = await (
|
||||
await transaction.prepare(
|
||||
`UPDATE mfa_totp_factors
|
||||
SET encrypted_secret=?,encryption_key_id=?
|
||||
WHERE id=? AND user_id=? AND state IN ('pending','active')`,
|
||||
)
|
||||
).run(
|
||||
rewrapped.envelope,
|
||||
rewrapped.activeKeyId,
|
||||
row.id,
|
||||
options.userId,
|
||||
);
|
||||
if (rewrappedRow.changes !== 1) throw new TotpServiceError();
|
||||
}
|
||||
return {
|
||||
status: 'verified',
|
||||
counter: matchedCounter,
|
||||
needsRewrap: decrypted.needsRewrap,
|
||||
keyId: decrypted.keyId,
|
||||
} as const;
|
||||
}
|
||||
return { status: 'replayed', lockedUntil: null } as const;
|
||||
}
|
||||
|
||||
const failedAttempts =
|
||||
row.locked_until && row.locked_until <= nowIso
|
||||
? 1
|
||||
: row.failed_attempts + 1;
|
||||
const locked = failedAttempts >= TOTP_FAILURE_LIMIT;
|
||||
await (
|
||||
await transaction.prepare(
|
||||
`UPDATE mfa_totp_factors
|
||||
SET failed_attempts=?,locked_until=?
|
||||
WHERE id=? AND user_id=? AND state IN ('pending','active')
|
||||
AND (locked_until IS NULL OR locked_until<=?)`,
|
||||
)
|
||||
).run(
|
||||
failedAttempts,
|
||||
locked ? lockUntil : null,
|
||||
row.id,
|
||||
options.userId,
|
||||
nowIso,
|
||||
);
|
||||
return {
|
||||
status: 'invalid',
|
||||
lockedUntil: locked ? lockUntil : null,
|
||||
} as const;
|
||||
}
|
||||
|
||||
export async function verifyTotpFactor(
|
||||
databaseInput: TotpDatabase | undefined,
|
||||
options: TotpVerificationOptions,
|
||||
): Promise<TotpVerificationResult> {
|
||||
const database = await databaseOrDefault(databaseInput);
|
||||
const verify = database.transactionAsync((transaction) =>
|
||||
verifyTotpFactorInTransaction(transaction, options),
|
||||
);
|
||||
return await verify.immediate();
|
||||
}
|
||||
|
||||
export const verifyTotp = verifyTotpFactor;
|
||||
|
||||
export async function revokeTotpFactor(
|
||||
databaseInput: TotpDatabase | undefined,
|
||||
options: { userId: string; factorId: string; now?: Date | string | number },
|
||||
): Promise<boolean> {
|
||||
const database = await databaseOrDefault(databaseInput);
|
||||
const now = asTimestamp(options.now).toISOString();
|
||||
const result = await (
|
||||
await database.prepare(
|
||||
"UPDATE mfa_totp_factors SET state='revoked',revoked_at=? WHERE id=? AND user_id=? AND state IN ('pending','active')",
|
||||
)
|
||||
).run(now, options.factorId, options.userId);
|
||||
return result.changes === 1;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
database: { prepare: vi.fn() },
|
||||
hasActiveTotpFactor: vi.fn(),
|
||||
isFullAdministrator: vi.fn(),
|
||||
createAuthenticationTransaction: vi.fn(),
|
||||
createSession: vi.fn(),
|
||||
recordLogin: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./bootstrap', () => ({
|
||||
hasActiveTotpFactor: mocks.hasActiveTotpFactor,
|
||||
isFullAdministrator: mocks.isFullAdministrator,
|
||||
}));
|
||||
vi.mock('./authentication-transactions', () => ({
|
||||
createAuthenticationTransaction: mocks.createAuthenticationTransaction,
|
||||
}));
|
||||
vi.mock('./session', () => ({
|
||||
createSession: mocks.createSession,
|
||||
recordLogin: mocks.recordLogin,
|
||||
}));
|
||||
|
||||
import { completePrimaryAuthentication } from './primary-authentication';
|
||||
|
||||
const providers = ['password', 'magic_link', 'discord', 'passkey'] as const;
|
||||
|
||||
describe('primary authentication completion', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.database.prepare.mockResolvedValue({
|
||||
get: vi.fn(async () => ({ id: 'user-1' })),
|
||||
});
|
||||
mocks.hasActiveTotpFactor.mockResolvedValue(true);
|
||||
mocks.isFullAdministrator.mockResolvedValue(false);
|
||||
mocks.createAuthenticationTransaction.mockResolvedValue({
|
||||
id: 'tx-1',
|
||||
userId: 'user-1',
|
||||
provider: 'password',
|
||||
kind: 'login',
|
||||
returnPath: '/account',
|
||||
requiredAal: 2,
|
||||
attemptCount: 0,
|
||||
expiresAt: '2026-08-07T12:05:00.000Z',
|
||||
});
|
||||
mocks.createSession.mockResolvedValue('session-1');
|
||||
});
|
||||
|
||||
it.each(providers)(
|
||||
'cannot bypass active MFA after %s verification',
|
||||
async (provider) => {
|
||||
const result = await completePrimaryAuthentication({
|
||||
database: mocks.database as never,
|
||||
cookies: {} as never,
|
||||
userId: 'user-1',
|
||||
provider,
|
||||
returnPath: '/account',
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
kind: 'mfa',
|
||||
redirectTo: '/auth/two-factor',
|
||||
});
|
||||
expect(mocks.createSession).not.toHaveBeenCalled();
|
||||
expect(mocks.createAuthenticationTransaction).toHaveBeenCalledWith(
|
||||
{},
|
||||
mocks.database,
|
||||
expect.objectContaining({
|
||||
userId: 'user-1',
|
||||
kind: 'login',
|
||||
provider,
|
||||
}),
|
||||
);
|
||||
expect(mocks.recordLogin).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
provider,
|
||||
false,
|
||||
expect.objectContaining({
|
||||
failureReason: 'mfa_required',
|
||||
riskFlags: ['primary_authenticated_mfa_required'],
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('gives an unenrolled administrator only an enrollment session', async () => {
|
||||
mocks.hasActiveTotpFactor.mockResolvedValue(false);
|
||||
mocks.isFullAdministrator.mockResolvedValue(true);
|
||||
|
||||
const result = await completePrimaryAuthentication({
|
||||
database: mocks.database as never,
|
||||
cookies: {} as never,
|
||||
userId: 'user-1',
|
||||
provider: 'password',
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
kind: 'session',
|
||||
aal: 1,
|
||||
redirectTo: '/account/security?required=admin',
|
||||
});
|
||||
expect(mocks.createAuthenticationTransaction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { Database } from '@tursodatabase/database';
|
||||
import type { Cookies } from '@sveltejs/kit';
|
||||
import { hasActiveTotpFactor, isFullAdministrator } from './bootstrap';
|
||||
import {
|
||||
createAuthenticationTransaction,
|
||||
type AuthenticationTransaction,
|
||||
} from './authentication-transactions';
|
||||
import { createSession, recordLogin, type SessionAuthLevel } from './session';
|
||||
|
||||
export type PrimaryAuthenticationProvider =
|
||||
'password' | 'discord' | 'magic_link' | 'passkey';
|
||||
|
||||
export class PrimaryAuthenticationError extends Error {
|
||||
readonly code: 'account_restricted' | 'account_missing';
|
||||
|
||||
constructor(code: 'account_restricted' | 'account_missing') {
|
||||
super('This account is not permitted to sign in.');
|
||||
this.name = 'PrimaryAuthenticationError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export type PrimaryAuthenticationResult =
|
||||
| {
|
||||
kind: 'session';
|
||||
sessionId: string;
|
||||
aal: SessionAuthLevel;
|
||||
redirectTo: string;
|
||||
}
|
||||
| {
|
||||
kind: 'mfa';
|
||||
transaction: AuthenticationTransaction;
|
||||
redirectTo: '/auth/two-factor';
|
||||
};
|
||||
|
||||
export type CompletePrimaryAuthenticationInput = {
|
||||
database: Database;
|
||||
cookies: Cookies;
|
||||
userId: string;
|
||||
provider: PrimaryAuthenticationProvider;
|
||||
request?: Request;
|
||||
getClientAddress?: () => string;
|
||||
credentialId?: string | null;
|
||||
returnPath?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Completes a verified password, email, Discord, or passkey identity. This is
|
||||
* the only primary-auth path allowed to create a session. Accounts with an
|
||||
* active TOTP factor receive an opaque MFA transaction instead.
|
||||
*/
|
||||
export async function completePrimaryAuthentication(
|
||||
input: CompletePrimaryAuthenticationInput,
|
||||
): Promise<PrimaryAuthenticationResult> {
|
||||
const status = await (
|
||||
await input.database.prepare(`
|
||||
SELECT users.id,user_status.blocked_at,user_status.banned_until,
|
||||
deleted_accounts.deleted_at
|
||||
FROM users
|
||||
LEFT JOIN user_status ON user_status.user_id=users.id
|
||||
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
|
||||
WHERE users.id=?
|
||||
`)
|
||||
).get(input.userId);
|
||||
const banned =
|
||||
typeof status?.banned_until === 'string' &&
|
||||
new Date(status.banned_until) > new Date();
|
||||
if (!status) throw new PrimaryAuthenticationError('account_missing');
|
||||
if (status.deleted_at || status.blocked_at || banned) {
|
||||
await recordLogin(input.userId, input.provider, false, {
|
||||
request: input.request,
|
||||
getClientAddress: input.getClientAddress,
|
||||
credentialId: input.credentialId,
|
||||
failureReason: 'account_restricted',
|
||||
});
|
||||
throw new PrimaryAuthenticationError('account_restricted');
|
||||
}
|
||||
|
||||
const activeFactor = await hasActiveTotpFactor(input.database, input.userId);
|
||||
const fullAdministrator = await isFullAdministrator(
|
||||
input.database,
|
||||
input.userId,
|
||||
);
|
||||
if (!activeFactor) {
|
||||
const sessionId = await createSession(input.cookies, input.userId, {
|
||||
aal: 1,
|
||||
});
|
||||
const redirectTo = fullAdministrator
|
||||
? '/account/security?required=admin'
|
||||
: (input.returnPath ?? '/account');
|
||||
await recordLogin(input.userId, input.provider, true, {
|
||||
request: input.request,
|
||||
getClientAddress: input.getClientAddress,
|
||||
credentialId: input.credentialId,
|
||||
sessionId,
|
||||
aal: 1,
|
||||
});
|
||||
return { kind: 'session', sessionId, aal: 1, redirectTo };
|
||||
}
|
||||
|
||||
const transaction = await createAuthenticationTransaction(
|
||||
input.cookies,
|
||||
input.database,
|
||||
{
|
||||
userId: input.userId,
|
||||
kind: 'login',
|
||||
provider: input.provider,
|
||||
returnPath: input.returnPath ?? '/account',
|
||||
},
|
||||
);
|
||||
await recordLogin(input.userId, input.provider, false, {
|
||||
request: input.request,
|
||||
getClientAddress: input.getClientAddress,
|
||||
credentialId: input.credentialId,
|
||||
failureReason: 'mfa_required',
|
||||
riskFlags: ['primary_authenticated_mfa_required'],
|
||||
aal: 1,
|
||||
});
|
||||
return { kind: 'mfa', transaction, redirectTo: '/auth/two-factor' };
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { connect, type Database } from '@tursodatabase/database';
|
||||
|
||||
const mocks = vi.hoisted(() => ({ database: null as Database | null }));
|
||||
|
||||
vi.mock('$lib/server/database', () => ({
|
||||
getDatabase: vi.fn(async () => mocks.database),
|
||||
}));
|
||||
vi.mock('$lib/avatar', () => ({
|
||||
avatarUrl: vi.fn(() => '/missing-item-icon.svg'),
|
||||
}));
|
||||
|
||||
import { createSession, getSessionUser } from './session';
|
||||
|
||||
function cookieJar() {
|
||||
const values = new Map<string, string>();
|
||||
return {
|
||||
values,
|
||||
get: (name: string) => values.get(name),
|
||||
set: vi.fn((name: string, value: string) => values.set(name, value)),
|
||||
delete: vi.fn((name: string) => values.delete(name)),
|
||||
};
|
||||
}
|
||||
|
||||
async function createDatabase(): Promise<Database> {
|
||||
const database = await connect(':memory:');
|
||||
await database.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY, username TEXT NOT NULL, login_username TEXT,
|
||||
role TEXT NOT NULL, discord_id TEXT
|
||||
);
|
||||
CREATE TABLE user_profiles (
|
||||
user_id TEXT PRIMARY KEY, display_name TEXT, public_slug TEXT
|
||||
);
|
||||
CREATE TABLE user_avatars (
|
||||
user_id TEXT PRIMARY KEY, source TEXT, value TEXT, updated_at TEXT
|
||||
);
|
||||
CREATE TABLE user_status (
|
||||
user_id TEXT PRIMARY KEY, last_active_at TEXT, blocked_at TEXT,
|
||||
banned_until TEXT, updated_at TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE TABLE deleted_accounts (user_id TEXT PRIMARY KEY, deleted_at TEXT);
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY, user_id TEXT NOT NULL, token_hash TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL, created_at TEXT NOT NULL, aal INTEGER NOT NULL,
|
||||
aal_verified_at TEXT NOT NULL, aal2_verified_at TEXT
|
||||
);
|
||||
INSERT INTO users(id,username,login_username,role,discord_id)
|
||||
VALUES ('user-1','agent','agent','user',NULL);
|
||||
INSERT INTO user_profiles(user_id,display_name,public_slug)
|
||||
VALUES ('user-1','Agent','agent');
|
||||
`);
|
||||
return database;
|
||||
}
|
||||
|
||||
describe('session AAL foundation', () => {
|
||||
let database: Database;
|
||||
|
||||
beforeEach(async () => {
|
||||
database = await createDatabase();
|
||||
mocks.database = database;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
mocks.database = null;
|
||||
await database.close();
|
||||
});
|
||||
|
||||
it('preserves legacy AAL1 calls and supports AAL2 metadata', async () => {
|
||||
const aal2Cookies = cookieJar();
|
||||
await createSession(aal2Cookies as never, 'user-1', { aal: 2 });
|
||||
const row = await (
|
||||
await database.prepare(
|
||||
'SELECT token_hash,aal,aal_verified_at,aal2_verified_at FROM sessions',
|
||||
)
|
||||
).get();
|
||||
expect(row.aal).toBe(2);
|
||||
expect(row.aal_verified_at).toBeTruthy();
|
||||
expect(row.aal2_verified_at).toBeTruthy();
|
||||
expect(String(row.token_hash)).not.toBe(
|
||||
aal2Cookies.values.get('shd_session'),
|
||||
);
|
||||
|
||||
const user = await getSessionUser(aal2Cookies as never);
|
||||
expect(user).toMatchObject({
|
||||
id: 'user-1',
|
||||
aal: 2,
|
||||
authLevel: 2,
|
||||
aalVerifiedAt: expect.any(String),
|
||||
aal2VerifiedAt: expect.any(String),
|
||||
});
|
||||
|
||||
const aal1Cookies = cookieJar();
|
||||
await createSession(aal1Cookies as never, 'user-1');
|
||||
const aal1 = await (
|
||||
await database.prepare(
|
||||
'SELECT aal,aal2_verified_at FROM sessions ORDER BY created_at DESC LIMIT 1',
|
||||
)
|
||||
).get();
|
||||
expect(aal1.aal).toBe(1);
|
||||
expect(aal1.aal2_verified_at).toBeNull();
|
||||
});
|
||||
});
|
||||
+138
-19
@@ -1,5 +1,6 @@
|
||||
import { dev } from '$app/environment';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import type { Database } from '@tursodatabase/database';
|
||||
import type { Cookies } from '@sveltejs/kit';
|
||||
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
||||
import { avatarUrl } from '$lib/avatar';
|
||||
@@ -16,6 +17,27 @@ export type AuthUser = {
|
||||
role: string;
|
||||
discordLinked: boolean;
|
||||
avatarUrl: string;
|
||||
/** Server-only authentication assurance metadata; no raw token is exposed. */
|
||||
aal: 1 | 2;
|
||||
authLevel: 1 | 2;
|
||||
sessionCreatedAt: string;
|
||||
aalVerifiedAt: string;
|
||||
aal2VerifiedAt: string | null;
|
||||
};
|
||||
|
||||
export type SessionAuthLevel = 1 | 2;
|
||||
export type SessionTransaction = Pick<Database, 'prepare'>;
|
||||
|
||||
export type PendingSession = {
|
||||
sessionId: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export type CreateSessionOptions = {
|
||||
aal?: SessionAuthLevel;
|
||||
authLevel?: SessionAuthLevel;
|
||||
aalVerifiedAt?: Date | string;
|
||||
aal2VerifiedAt?: Date | string | null;
|
||||
};
|
||||
|
||||
type LoginRequestContext = {
|
||||
@@ -25,23 +47,43 @@ type LoginRequestContext = {
|
||||
credentialId?: string | null;
|
||||
sessionId?: string | null;
|
||||
riskFlags?: string[] | null;
|
||||
aal?: SessionAuthLevel;
|
||||
aal2VerifiedAt?: string | null;
|
||||
};
|
||||
|
||||
function hashToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
export async function createSession(
|
||||
cookies: Cookies,
|
||||
function sessionCookieOptions() {
|
||||
return {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax' as const,
|
||||
secure: !dev,
|
||||
maxAge: SESSION_SECONDS,
|
||||
};
|
||||
}
|
||||
|
||||
export function setSessionCookie(cookies: Cookies, token: string): void {
|
||||
cookies.set(COOKIE_NAME, token, sessionCookieOptions());
|
||||
}
|
||||
|
||||
export async function createSessionInTransaction(
|
||||
transaction: SessionTransaction,
|
||||
userId: string,
|
||||
): Promise<string> {
|
||||
const database = await getDatabase();
|
||||
options: CreateSessionOptions = {},
|
||||
): Promise<PendingSession> {
|
||||
const token = randomBytes(32).toString('base64url');
|
||||
const sessionId = randomUUID();
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + SESSION_SECONDS * 1000);
|
||||
const insert = await database.prepare(
|
||||
'INSERT INTO sessions (id, user_id, token_hash, expires_at, created_at) VALUES (?, ?, ?, ?, ?)',
|
||||
const aal = options.authLevel ?? options.aal ?? 1;
|
||||
const aalVerifiedAt = new Date(options.aalVerifiedAt ?? now).toISOString();
|
||||
const aal2VerifiedAt =
|
||||
aal === 2 ? new Date(options.aal2VerifiedAt ?? now).toISOString() : null;
|
||||
const insert = await transaction.prepare(
|
||||
'INSERT INTO sessions (id, user_id, token_hash, expires_at, created_at, aal, aal_verified_at, aal2_verified_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
);
|
||||
|
||||
await insert.run(
|
||||
@@ -50,16 +92,67 @@ export async function createSession(
|
||||
hashToken(token),
|
||||
expiresAt.toISOString(),
|
||||
now.toISOString(),
|
||||
aal,
|
||||
aalVerifiedAt,
|
||||
aal2VerifiedAt,
|
||||
);
|
||||
|
||||
cookies.set(COOKIE_NAME, token, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: !dev,
|
||||
maxAge: SESSION_SECONDS,
|
||||
});
|
||||
return sessionId;
|
||||
return { sessionId, token };
|
||||
}
|
||||
|
||||
export async function createSession(
|
||||
cookies: Cookies,
|
||||
userId: string,
|
||||
options: CreateSessionOptions = {},
|
||||
): Promise<string> {
|
||||
const database = await getDatabase();
|
||||
const operation = database.transactionAsync((transaction) =>
|
||||
createSessionInTransaction(transaction, userId, options),
|
||||
);
|
||||
const session = await operation.immediate();
|
||||
setSessionCookie(cookies, session.token);
|
||||
return session.sessionId;
|
||||
}
|
||||
|
||||
/** Upgrade the current cookie-backed session without exposing or reissuing its token. */
|
||||
export async function upgradeSessionToAal2InTransaction(
|
||||
transaction: SessionTransaction,
|
||||
cookies: Cookies,
|
||||
userId: string,
|
||||
verifiedAt = new Date(),
|
||||
): Promise<string | null> {
|
||||
const token = cookies.get(COOKIE_NAME);
|
||||
if (!token) return null;
|
||||
const result = await (
|
||||
await transaction.prepare(`
|
||||
UPDATE sessions
|
||||
SET aal=2,aal_verified_at=?,aal2_verified_at=?
|
||||
WHERE token_hash=? AND user_id=? AND expires_at>?
|
||||
`)
|
||||
).run(
|
||||
verifiedAt.toISOString(),
|
||||
verifiedAt.toISOString(),
|
||||
hashToken(token),
|
||||
userId,
|
||||
new Date().toISOString(),
|
||||
);
|
||||
if (result.changes !== 1) return null;
|
||||
const row = await (
|
||||
await transaction.prepare('SELECT id FROM sessions WHERE token_hash=?')
|
||||
).get(hashToken(token));
|
||||
return row?.id ? String(row.id) : null;
|
||||
}
|
||||
|
||||
export async function upgradeSessionToAal2(
|
||||
cookies: Cookies,
|
||||
userId: string,
|
||||
verifiedAt = new Date(),
|
||||
): Promise<string | null> {
|
||||
const database = await getDatabase();
|
||||
const operation = database.transactionAsync((transaction) =>
|
||||
upgradeSessionToAal2InTransaction(transaction, cookies, userId, verifiedAt),
|
||||
);
|
||||
return await operation.immediate();
|
||||
}
|
||||
|
||||
function describeClient(userAgent: string) {
|
||||
@@ -110,6 +203,23 @@ export async function recordLogin(
|
||||
context: LoginRequestContext = {},
|
||||
): Promise<void> {
|
||||
const database = await getDatabase();
|
||||
await recordLoginInTransaction(
|
||||
database,
|
||||
userId,
|
||||
provider,
|
||||
succeeded,
|
||||
context,
|
||||
);
|
||||
await purgeExpiredLoginMetadata();
|
||||
}
|
||||
|
||||
export async function recordLoginInTransaction(
|
||||
transaction: SessionTransaction,
|
||||
userId: string | null,
|
||||
provider: 'password' | 'discord' | 'magic_link' | 'passkey',
|
||||
succeeded: boolean,
|
||||
context: LoginRequestContext = {},
|
||||
): Promise<void> {
|
||||
const userAgent =
|
||||
context.request?.headers.get('user-agent')?.slice(0, 1000) ?? null;
|
||||
const headers = context.request?.headers;
|
||||
@@ -134,10 +244,10 @@ export async function recordLogin(
|
||||
/* unavailable in some development adapters */
|
||||
}
|
||||
await (
|
||||
await database.prepare(`INSERT INTO login_events(id,user_id,provider,succeeded,ip_address,user_agent,
|
||||
await transaction.prepare(`INSERT INTO login_events(id,user_id,provider,succeeded,ip_address,user_agent,
|
||||
client_name,operating_system,device_type,location_country,location_region,location_city,
|
||||
failure_reason,risk_flags,credential_id,session_id,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
||||
failure_reason,risk_flags,credential_id,session_id,aal,aal2_verified_at,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
||||
).run(
|
||||
randomUUID(),
|
||||
userId,
|
||||
@@ -155,9 +265,10 @@ export async function recordLogin(
|
||||
riskFlags.length ? riskFlags.join(',') : null,
|
||||
context.credentialId ?? null,
|
||||
context.sessionId ?? null,
|
||||
context.aal ?? (succeeded ? 1 : null),
|
||||
context.aal2VerifiedAt ?? null,
|
||||
new Date().toISOString(),
|
||||
);
|
||||
await purgeExpiredLoginMetadata();
|
||||
}
|
||||
|
||||
export async function getSessionUser(
|
||||
@@ -173,7 +284,9 @@ export async function getSessionUser(
|
||||
users.role, users.discord_id, user_avatars.source AS avatar_source,
|
||||
user_avatars.value AS avatar_value, user_avatars.updated_at AS avatar_updated_at,
|
||||
user_status.last_active_at,user_status.blocked_at,user_status.banned_until,
|
||||
deleted_accounts.deleted_at
|
||||
deleted_accounts.deleted_at,
|
||||
sessions.aal,sessions.aal_verified_at,sessions.aal2_verified_at,
|
||||
sessions.created_at AS session_created_at
|
||||
FROM sessions
|
||||
JOIN users ON users.id = sessions.user_id
|
||||
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
|
||||
@@ -224,6 +337,12 @@ export async function getSessionUser(
|
||||
String(user.id),
|
||||
user.avatar_updated_at,
|
||||
),
|
||||
aal: Number(user.aal) === 2 ? 2 : 1,
|
||||
authLevel: Number(user.aal) === 2 ? 2 : 1,
|
||||
sessionCreatedAt: String(user.session_created_at),
|
||||
aalVerifiedAt: String(user.aal_verified_at),
|
||||
aal2VerifiedAt:
|
||||
typeof user.aal2_verified_at === 'string' ? user.aal2_verified_at : null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import sharp from 'sharp';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
BUILD_SOCIAL_CARD_HEIGHT,
|
||||
BUILD_SOCIAL_CARD_WIDTH,
|
||||
buildBuildSocialCardSvg,
|
||||
renderBuildSocialCardPng,
|
||||
type BuildSocialCardInput,
|
||||
} from './build-social-card';
|
||||
|
||||
const input: BuildSocialCardInput = {
|
||||
title: 'Vanguard Counter-Offensive',
|
||||
author: 'Agent Kelso',
|
||||
primaryWeapon: 'Police M4',
|
||||
secondaryWeapon: 'Super 90',
|
||||
firepower: 1280,
|
||||
toughness: 940,
|
||||
engineering: 615,
|
||||
supportingAttributes: [
|
||||
{ label: 'Critical Hit Chance', value: '18.5%' },
|
||||
{ label: 'Headshot Damage', value: '42%' },
|
||||
{ label: 'Armor', value: '12,400' },
|
||||
],
|
||||
visibility: 'public',
|
||||
directUrl: 'shd.build/builds/vanguard-counter-offensive',
|
||||
};
|
||||
|
||||
describe('build social card', () => {
|
||||
it('escapes XML injection and keeps inputs out of markup and URLs', () => {
|
||||
const attack = `</text><script href="https://attacker.invalid">alert('x') & more</script>`;
|
||||
const svg = buildBuildSocialCardSvg({
|
||||
...input,
|
||||
title: attack,
|
||||
author: `A\u0000gent <&> "one" 'two'`,
|
||||
directUrl: `javascript:alert(1)\n${attack}`,
|
||||
supportingAttributes: [{ label: attack, value: attack }],
|
||||
});
|
||||
|
||||
expect(svg).not.toContain('<script');
|
||||
expect(svg).not.toContain('href="https://attacker.invalid"');
|
||||
expect(svg).not.toContain('\u0000');
|
||||
expect(svg).toContain(
|
||||
'</text><script href="https://attacker.invalid">',
|
||||
);
|
||||
expect(svg).toContain(
|
||||
'A gent <&> "one" 'two'',
|
||||
);
|
||||
expect(svg).not.toMatch(/<[^>]+\s(?:href|xlink:href)\s*=/gu);
|
||||
});
|
||||
|
||||
it('wraps and truncates long Unicode text without splitting graphemes', () => {
|
||||
const family = '👨👩👧👦';
|
||||
const svg = buildBuildSocialCardSvg({
|
||||
...input,
|
||||
title: `${family.repeat(31)} ${family.repeat(31)} ${family.repeat(31)}`,
|
||||
author: `Zoë ${'e\u0301'.repeat(60)}`,
|
||||
directUrl: `shd.build/builds/${'路'.repeat(130)}`,
|
||||
});
|
||||
|
||||
const titleBlock =
|
||||
svg.match(/<text class="title">(.*?)<\/text>/u)?.[1] ?? '';
|
||||
expect(titleBlock.match(/<tspan/gu)).toHaveLength(2);
|
||||
expect(titleBlock).toContain('…');
|
||||
expect(titleBlock).not.toMatch(
|
||||
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/gu,
|
||||
);
|
||||
expect(svg).toContain('é');
|
||||
expect(svg).not.toContain('é');
|
||||
expect(svg).toContain(`${'路'.repeat(20)}`);
|
||||
expect(svg).toContain('…');
|
||||
});
|
||||
|
||||
it('builds deterministic SVG with bounded supporting facts', () => {
|
||||
const withExtraFacts = {
|
||||
...input,
|
||||
supportingAttributes: [
|
||||
...input.supportingAttributes!,
|
||||
{ label: 'Must not render', value: '999' },
|
||||
],
|
||||
};
|
||||
const first = buildBuildSocialCardSvg(withExtraFacts);
|
||||
const second = buildBuildSocialCardSvg(withExtraFacts);
|
||||
|
||||
expect(first).toBe(second);
|
||||
expect(first).toContain('width="1200" height="630"');
|
||||
expect(first).toContain('SHD</text><text');
|
||||
expect(first).toContain('.build');
|
||||
expect(first).toContain('font-family:"DejaVu Sans Condensed",sans-serif');
|
||||
expect(first).toContain('SUPPORTING ATTRIBUTES / 03');
|
||||
expect(first).not.toContain('Must not render');
|
||||
});
|
||||
|
||||
it('renders a valid 1200x630 PNG through Sharp', async () => {
|
||||
const png = await renderBuildSocialCardPng(input);
|
||||
const metadata = await sharp(png).metadata();
|
||||
|
||||
expect(png.subarray(0, 8)).toEqual(
|
||||
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
|
||||
);
|
||||
expect(metadata.format).toBe('png');
|
||||
expect(metadata.width).toBe(BUILD_SOCIAL_CARD_WIDTH);
|
||||
expect(metadata.height).toBe(BUILD_SOCIAL_CARD_HEIGHT);
|
||||
expect(png.byteLength).toBeLessThan(1_000_000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import sharp from 'sharp';
|
||||
|
||||
export const BUILD_SOCIAL_CARD_WIDTH = 1200;
|
||||
export const BUILD_SOCIAL_CARD_HEIGHT = 630;
|
||||
|
||||
export type BuildVisibility = 'public' | 'unlisted' | 'private';
|
||||
|
||||
export interface BuildSocialCardFact {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface BuildSocialCardInput {
|
||||
title: string;
|
||||
author: string;
|
||||
primaryWeapon: string;
|
||||
secondaryWeapon: string;
|
||||
firepower: string | number;
|
||||
toughness: string | number;
|
||||
engineering: string | number;
|
||||
supportingAttributes?: readonly BuildSocialCardFact[];
|
||||
visibility: BuildVisibility;
|
||||
directUrl: string;
|
||||
}
|
||||
|
||||
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
|
||||
|
||||
function graphemes(value: string): string[] {
|
||||
return Array.from(segmenter.segment(value), ({ segment }) => segment);
|
||||
}
|
||||
|
||||
function normalizeText(value: string | number, maximum: number): string {
|
||||
const normalized = String(value)
|
||||
.normalize('NFC')
|
||||
.replace(/[\u0000-\u001f\u007f-\u009f]/gu, ' ')
|
||||
.replace(/\s+/gu, ' ')
|
||||
.trim();
|
||||
const characters = graphemes(normalized);
|
||||
if (characters.length <= maximum) return normalized;
|
||||
return `${characters
|
||||
.slice(0, Math.max(0, maximum - 1))
|
||||
.join('')
|
||||
.trimEnd()}…`;
|
||||
}
|
||||
|
||||
function escapeXml(value: string): string {
|
||||
return value
|
||||
.replace(/&/gu, '&')
|
||||
.replace(/</gu, '<')
|
||||
.replace(/>/gu, '>')
|
||||
.replace(/"/gu, '"')
|
||||
.replace(/'/gu, ''');
|
||||
}
|
||||
|
||||
function wrapText(
|
||||
value: string,
|
||||
width: number,
|
||||
maximumLines: number,
|
||||
): string[] {
|
||||
const characters = graphemes(value);
|
||||
const lines: string[] = [];
|
||||
let offset = 0;
|
||||
|
||||
while (offset < characters.length && lines.length < maximumLines) {
|
||||
const remaining = characters.slice(offset);
|
||||
if (remaining.length <= width) {
|
||||
lines.push(remaining.join('').trim());
|
||||
offset = characters.length;
|
||||
break;
|
||||
}
|
||||
|
||||
const candidate = remaining.slice(0, width + 1);
|
||||
let splitAt = -1;
|
||||
for (
|
||||
let index = Math.min(width, candidate.length - 1);
|
||||
index > 0;
|
||||
index--
|
||||
) {
|
||||
if (/\s/u.test(candidate[index])) {
|
||||
splitAt = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (splitAt < Math.floor(width * 0.55)) splitAt = width;
|
||||
lines.push(remaining.slice(0, splitAt).join('').trim());
|
||||
offset += splitAt;
|
||||
while (offset < characters.length && /\s/u.test(characters[offset]))
|
||||
offset++;
|
||||
}
|
||||
|
||||
if (offset < characters.length && lines.length > 0) {
|
||||
const last = graphemes(lines.at(-1) ?? '');
|
||||
lines[lines.length - 1] = `${last
|
||||
.slice(0, Math.max(0, width - 1))
|
||||
.join('')
|
||||
.trimEnd()}…`;
|
||||
}
|
||||
return lines.length > 0 ? lines : ['UNTITLED BUILD'];
|
||||
}
|
||||
|
||||
function textLines(
|
||||
lines: readonly string[],
|
||||
x: number,
|
||||
y: number,
|
||||
lineHeight: number,
|
||||
): string {
|
||||
return lines
|
||||
.map(
|
||||
(line, index) =>
|
||||
`<tspan x="${x}" y="${y + index * lineHeight}">${escapeXml(line)}</tspan>`,
|
||||
)
|
||||
.join('');
|
||||
}
|
||||
|
||||
function visibilityLabel(visibility: BuildVisibility): string {
|
||||
if (visibility === 'public') return 'PUBLIC';
|
||||
if (visibility === 'unlisted') return 'UNLISTED';
|
||||
return 'PRIVATE';
|
||||
}
|
||||
|
||||
/** Builds the complete, text-only SVG supplied to Sharp. */
|
||||
export function buildBuildSocialCardSvg(input: BuildSocialCardInput): string {
|
||||
const title = wrapText(normalizeText(input.title, 112), 31, 2);
|
||||
const author = normalizeText(input.author, 42) || 'UNKNOWN AGENT';
|
||||
const primaryWeapon = normalizeText(input.primaryWeapon, 52) || 'UNSPECIFIED';
|
||||
const secondaryWeapon =
|
||||
normalizeText(input.secondaryWeapon, 52) || 'UNSPECIFIED';
|
||||
const firepower = normalizeText(input.firepower, 18) || '—';
|
||||
const toughness = normalizeText(input.toughness, 18) || '—';
|
||||
const engineering = normalizeText(input.engineering, 18) || '—';
|
||||
const directUrl = normalizeText(input.directUrl, 92) || 'shd.build';
|
||||
const facts = (input.supportingAttributes ?? []).slice(0, 3).map((fact) => ({
|
||||
label: normalizeText(fact.label.toUpperCase(), 28) || 'ATTRIBUTE',
|
||||
value: normalizeText(fact.value, 30) || '—',
|
||||
}));
|
||||
|
||||
const factMarkup = facts
|
||||
.map((fact, index) => {
|
||||
const y = 421 + index * 48;
|
||||
return `<g><line x1="746" y1="${y + 13}" x2="764" y2="${y + 13}" class="signal"/><text x="778" y="${y}" class="fact-label">${escapeXml(fact.label)}</text><text x="778" y="${y + 22}" class="fact-value">${escapeXml(fact.value)}</text></g>`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630" role="img" aria-label="SHD.build build card">
|
||||
<style>
|
||||
text{font-family:"DejaVu Sans Condensed",sans-serif}.telemetry{font-size:13px;font-weight:700;letter-spacing:2px;fill:#858b8d}.title{font-size:54px;font-weight:900;letter-spacing:-1px;fill:#ecebe5}.weapon{font-size:25px;font-weight:800;fill:#ecebe5}.stat-label,.fact-label{font-size:12px;font-weight:800;letter-spacing:1.5px;fill:#858b8d}.stat-value{font-size:31px;font-weight:900;fill:#ecebe5}.fact-value{font-size:19px;font-weight:700;fill:#ecebe5}.signal{stroke:#f6a821;stroke-width:2}.grid{stroke:#272c2d;stroke-width:1}.panel{fill:#111415;stroke:#303536;stroke-width:1}
|
||||
</style>
|
||||
<rect width="1200" height="630" fill="#0a0c0d"/><path d="M0 96H1200M0 594H1200M710 96V594" class="grid"/><path d="M28 28H76M28 28V76M1172 28H1124M1172 28V76M28 602H76M28 602V554M1172 602H1124M1172 602V554" class="signal"/>
|
||||
<g transform="translate(52 45)"><text x="0" y="0" font-size="34" font-weight="900" fill="#ecebe5">SHD</text><text x="67" y="0" font-size="34" font-weight="900" fill="#f6a821">.build</text><text x="191" y="-2" class="telemetry">BUILD DOSSIER / SOCIAL TRANSMISSION</text></g>
|
||||
<text x="52" y="139" class="telemetry">AGENT LOADOUT</text><text class="title">${textLines(title, 52, 201, 61)}</text><text x="52" y="294" class="telemetry">AUTHORED BY</text><text x="164" y="294" font-size="18" font-weight="800" fill="#ecebe5">${escapeXml(author)}</text>
|
||||
<g><rect x="52" y="328" width="622" height="88" class="panel"/><rect x="52" y="328" width="4" height="88" fill="#f6a821"/><text x="76" y="356" class="stat-label">PRIMARY WEAPON</text><text x="76" y="391" class="weapon">${escapeXml(primaryWeapon)}</text></g>
|
||||
<g><rect x="52" y="430" width="622" height="88" class="panel"/><text x="76" y="458" class="stat-label">SECONDARY WEAPON</text><text x="76" y="493" class="weapon">${escapeXml(secondaryWeapon)}</text></g>
|
||||
<g transform="translate(746 132)"><text x="0" y="0" class="telemetry">CORE ATTRIBUTES</text><rect x="0" y="24" width="402" height="174" class="panel"/><line x1="134" y1="24" x2="134" y2="198" class="grid"/><line x1="268" y1="24" x2="268" y2="198" class="grid"/><text x="18" y="66" class="stat-label">FIREPOWER</text><text x="18" y="112" class="stat-value">${escapeXml(firepower)}</text><text x="152" y="66" class="stat-label">TOUGHNESS</text><text x="152" y="112" class="stat-value">${escapeXml(toughness)}</text><text x="286" y="66" class="stat-label">ENGINEERING</text><text x="286" y="112" class="stat-value">${escapeXml(engineering)}</text><path d="M18 166H116M152 166H250M286 166H384" class="signal"/></g>
|
||||
<text x="746" y="374" class="telemetry">SUPPORTING ATTRIBUTES / ${String(facts.length).padStart(2, '0')}</text>${factMarkup}
|
||||
<g><text x="52" y="566" class="telemetry">DIRECT LINK</text><text x="157" y="566" font-size="15" font-weight="700" fill="#ecebe5">${escapeXml(directUrl)}</text><rect x="972" y="541" width="176" height="36" fill="#15191a" stroke="#f6a821"/><text x="1060" y="564" text-anchor="middle" font-size="13" font-weight="900" letter-spacing="2" fill="#f6a821">${visibilityLabel(input.visibility)}</text></g>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
/** Renders a deterministic 1200x630 PNG without loading external resources. */
|
||||
export async function renderBuildSocialCardPng(
|
||||
input: BuildSocialCardInput,
|
||||
): Promise<Buffer> {
|
||||
return sharp(Buffer.from(buildBuildSocialCardSvg(input)), {
|
||||
density: 72,
|
||||
failOn: 'error',
|
||||
unlimited: false,
|
||||
})
|
||||
.png({ compressionLevel: 9, adaptiveFiltering: false, palette: false })
|
||||
.toBuffer();
|
||||
}
|
||||
@@ -187,6 +187,7 @@ describe('conditional mechanic persistence adapter', () => {
|
||||
value_scaling TEXT, unit TEXT, damage_basis TEXT, application_phase TEXT,
|
||||
combat_timing TEXT, resource_kind TEXT, delivery_mode TEXT,
|
||||
delivery_duration_seconds REAL, targeting_json TEXT, min_state_value REAL, max_state_value REAL,
|
||||
probability_percent REAL,
|
||||
description_slot INTEGER, sort_order INTEGER, created_at TEXT, updated_at TEXT
|
||||
)`);
|
||||
|
||||
@@ -220,6 +221,8 @@ describe('conditional mechanic persistence adapter', () => {
|
||||
key: 'weapon-kill',
|
||||
label: 'Weapon kill',
|
||||
event: 'enemy_killed',
|
||||
procChancePercent: 29,
|
||||
requiresAssignedAbilityActive: true,
|
||||
if: {
|
||||
kind: 'compare',
|
||||
left: {
|
||||
@@ -255,6 +258,7 @@ describe('conditional mechanic persistence adapter', () => {
|
||||
population: 'allies',
|
||||
radiusMeters: 40,
|
||||
},
|
||||
probabilityPercent: 29,
|
||||
variants: {
|
||||
I: { value: 240 },
|
||||
II: { value: 336 },
|
||||
@@ -263,12 +267,18 @@ describe('conditional mechanic persistence adapter', () => {
|
||||
{
|
||||
key: 'target-detonation',
|
||||
label: 'Target Detonation',
|
||||
phase: 'on_exit',
|
||||
phase: 'after_event',
|
||||
kind: 'direct_damage',
|
||||
scope: 'target',
|
||||
basis: 'flat',
|
||||
statePool: 'marked-target',
|
||||
perStack: true,
|
||||
targeting: {
|
||||
kind: 'event_target_area',
|
||||
center: 'event_target',
|
||||
validTargets: 'enemies',
|
||||
radiusMeters: 5,
|
||||
},
|
||||
variants: {
|
||||
I: {
|
||||
value: 240,
|
||||
@@ -322,6 +332,23 @@ describe('conditional mechanic persistence adapter', () => {
|
||||
mutations: [],
|
||||
outcomes: [],
|
||||
},
|
||||
{
|
||||
key: 'tactical-link-assist',
|
||||
label: 'Tactical Link assist',
|
||||
event: 'assist',
|
||||
if: {
|
||||
kind: 'compare',
|
||||
left: {
|
||||
source: 'event',
|
||||
path: 'values.assignedAbilityActive',
|
||||
},
|
||||
operator: 'eq',
|
||||
right: true,
|
||||
},
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [],
|
||||
outcomes: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
@@ -366,11 +393,23 @@ describe('conditional mechanic persistence adapter', () => {
|
||||
population: 'allies',
|
||||
radiusMeters: 40,
|
||||
},
|
||||
probabilityPercent: 29,
|
||||
variants: {
|
||||
I: { value: 240 },
|
||||
II: { value: 336 },
|
||||
},
|
||||
});
|
||||
expect(loaded?.clauses[0].outcomes[1]).toMatchObject({
|
||||
phase: 'after_event',
|
||||
kind: 'direct_damage',
|
||||
scope: 'target',
|
||||
targeting: {
|
||||
kind: 'event_target_area',
|
||||
center: 'event_target',
|
||||
validTargets: 'enemies',
|
||||
radiusMeters: 5,
|
||||
},
|
||||
});
|
||||
expect(loaded?.clauses[0].if).toEqual({
|
||||
kind: 'compare',
|
||||
left: {
|
||||
@@ -381,6 +420,10 @@ describe('conditional mechanic persistence adapter', () => {
|
||||
operator: 'lt',
|
||||
right: 75,
|
||||
});
|
||||
expect(loaded?.clauses[0]).toMatchObject({
|
||||
procChancePercent: 29,
|
||||
requiresAssignedAbilityActive: true,
|
||||
});
|
||||
expect(loaded?.pools[0]).toMatchObject({
|
||||
key: 'marked-target',
|
||||
stateKind: 'target_mark',
|
||||
@@ -399,7 +442,7 @@ describe('conditional mechanic persistence adapter', () => {
|
||||
},
|
||||
]);
|
||||
expect(loaded?.clauses[0].outcomes[1]).toMatchObject({
|
||||
phase: 'on_exit',
|
||||
phase: 'after_event',
|
||||
kind: 'direct_damage',
|
||||
scope: 'target',
|
||||
statePool: 'marked-target',
|
||||
@@ -430,5 +473,14 @@ describe('conditional mechanic persistence adapter', () => {
|
||||
right: true,
|
||||
},
|
||||
});
|
||||
expect(loaded?.clauses[2]).toMatchObject({
|
||||
event: 'assist',
|
||||
if: {
|
||||
kind: 'compare',
|
||||
left: { source: 'event', path: 'values.assignedAbilityActive' },
|
||||
operator: 'eq',
|
||||
right: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,6 +90,7 @@ const outcomeMetadataColumns = [
|
||||
'delivery_mode',
|
||||
'delivery_duration_seconds',
|
||||
'targeting_json',
|
||||
'probability_percent',
|
||||
'state_pool_id',
|
||||
'min_state_value',
|
||||
'max_state_value',
|
||||
@@ -205,6 +206,10 @@ export function conditionalRuleDraftFromRows(
|
||||
.filter((row) => row.rule_id === rule.id && Number(row.active ?? 1) === 1)
|
||||
.sort((left, right) => Number(left.sort_order) - Number(right.sort_order))
|
||||
.map((row): ConditionalMechanicClauseDraft => {
|
||||
const parameters = parseJson<Record<string, unknown>>(
|
||||
row.recipe_parameters_json,
|
||||
{},
|
||||
);
|
||||
const grouped = new Map<string, OutcomeRow[]>();
|
||||
for (const outcome of rowsByClause.get(String(row.id)) ?? []) {
|
||||
const key = String(outcome.effect_key);
|
||||
@@ -268,6 +273,10 @@ export function conditionalRuleDraftFromRows(
|
||||
...(first.targeting_json
|
||||
? { targeting: parseJson(first.targeting_json, undefined) }
|
||||
: {}),
|
||||
...(first.probability_percent === null ||
|
||||
first.probability_percent === undefined
|
||||
? {}
|
||||
: { probabilityPercent: Number(first.probability_percent) }),
|
||||
...(first.stat_attribute_id
|
||||
? { attributeId: String(first.stat_attribute_id) }
|
||||
: {}),
|
||||
@@ -329,6 +338,15 @@ export function conditionalRuleDraftFromRows(
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
...(typeof parameters.procChancePercent === 'number'
|
||||
? { procChancePercent: parameters.procChancePercent }
|
||||
: {}),
|
||||
...(typeof parameters.requiresAssignedAbilityActive === 'boolean'
|
||||
? {
|
||||
requiresAssignedAbilityActive:
|
||||
parameters.requiresAssignedAbilityActive,
|
||||
}
|
||||
: {}),
|
||||
...(row.cooldown_seconds === null || row.cooldown_seconds === undefined
|
||||
? {}
|
||||
: { cooldownSeconds: Number(row.cooldown_seconds) }),
|
||||
@@ -495,7 +513,17 @@ export async function saveConditionalRuleInTransaction(
|
||||
clause.label || clause.key,
|
||||
clause.event,
|
||||
clause.recipeKey ?? null,
|
||||
'{}',
|
||||
JSON.stringify({
|
||||
...(clause.procChancePercent === undefined
|
||||
? {}
|
||||
: { procChancePercent: clause.procChancePercent }),
|
||||
...(clause.requiresAssignedAbilityActive === undefined
|
||||
? {}
|
||||
: {
|
||||
requiresAssignedAbilityActive:
|
||||
clause.requiresAssignedAbilityActive,
|
||||
}),
|
||||
}),
|
||||
JSON.stringify(clause.if ?? { kind: 'true' }),
|
||||
JSON.stringify(clause.reducer),
|
||||
clause.weight === undefined ? null : JSON.stringify(clause.weight),
|
||||
@@ -518,9 +546,9 @@ export async function saveConditionalRuleInTransaction(
|
||||
application_kind,effect_scope,stat_attribute_id,value,value_expression_json,
|
||||
value_basis,value_scaling,unit,damage_basis,application_phase,combat_timing,
|
||||
resource_kind,delivery_mode,delivery_duration_seconds,
|
||||
targeting_json,min_state_value,max_state_value,description_slot,sort_order,
|
||||
targeting_json,probability_percent,min_state_value,max_state_value,description_slot,sort_order,
|
||||
created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
||||
).run(
|
||||
randomUUID(),
|
||||
ruleId,
|
||||
@@ -552,6 +580,7 @@ export async function saveConditionalRuleInTransaction(
|
||||
outcome.targeting === undefined
|
||||
? null
|
||||
: JSON.stringify(outcome.targeting),
|
||||
outcome.probabilityPercent ?? null,
|
||||
outcome.minStacks ?? null,
|
||||
outcome.maxStacks ?? null,
|
||||
outcome.descriptionSlot ?? null,
|
||||
|
||||
@@ -37,14 +37,31 @@ const proposalResultKeys: Partial<
|
||||
saveAugmentProfile: { saved: 'augmentProfileSaved' },
|
||||
},
|
||||
weapons: { save: { saved: 'weaponSaved', mode: 'weaponMode' } },
|
||||
weaponmods: { save: { saved: 'weaponModSaved', mode: 'weaponModMode' } },
|
||||
gearmods: { save: { saved: 'gearModSaved', mode: 'gearModMode' } },
|
||||
weaponmods: {
|
||||
save: { saved: 'weaponModSaved', mode: 'weaponModMode' },
|
||||
saveSlotIcon: { saved: 'weaponModSlotIconSaved' },
|
||||
saveSecondaryPool: {
|
||||
saved: 'weaponModSecondaryPoolSaved',
|
||||
mode: 'weaponModSecondaryPoolMode',
|
||||
},
|
||||
},
|
||||
gearmods: {
|
||||
save: { saved: 'gearModSaved', mode: 'gearModMode' },
|
||||
saveSecondaryPool: {
|
||||
saved: 'gearModSecondaryPoolSaved',
|
||||
mode: 'gearModSecondaryPoolMode',
|
||||
},
|
||||
},
|
||||
talents: { saveTalent: { saved: 'talentSaved', mode: 'talentMode' } },
|
||||
protocols: {
|
||||
save: { saved: 'protocolSaved', mode: 'protocolMode' },
|
||||
saveProfile: { saved: 'profileSaved' },
|
||||
},
|
||||
skillchips: { save: { saved: 'skillChipSaved', mode: 'skillChipMode' } },
|
||||
skillchips: {
|
||||
save: { saved: 'skillChipSaved', mode: 'skillChipMode' },
|
||||
savePrimaryProfile: { saved: 'skillChipPrimaryProfileSaved' },
|
||||
saveSecondaryPool: { saved: 'skillChipSecondaryPoolSaved' },
|
||||
},
|
||||
specializations: {
|
||||
saveProgression: { saved: 'progressionSaved' },
|
||||
saveSpecialization: {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
BackgroundRemovalError,
|
||||
removeBackgroundPreview,
|
||||
} from './remove-background';
|
||||
import { weaponModSlotDefinitionById } from '$lib/weapons/mod-slots';
|
||||
|
||||
export const GAME_ICON_TYPES = [
|
||||
'talent',
|
||||
@@ -16,6 +17,7 @@ export const GAME_ICON_TYPES = [
|
||||
'specialization',
|
||||
'specialization_ability',
|
||||
'specialization_node',
|
||||
'weapon_mod_slot',
|
||||
] as const;
|
||||
export type GameDataIconType = (typeof GAME_ICON_TYPES)[number];
|
||||
export const MAX_GAME_ICON_BYTES = 2 * 1024 * 1024;
|
||||
@@ -23,7 +25,7 @@ export const MAX_GAME_ICON_PIXELS = 12_000_000;
|
||||
export const GAME_ICON_SIZE = 256;
|
||||
const ENTITY_ID_PATTERN = /^[0-9a-f-]{36}$/i;
|
||||
const STORAGE_KEY_PATTERN =
|
||||
/^(talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i;
|
||||
/^(talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node|weapon_mod_slot)-[0-9a-f-]{36}\.webp$/i;
|
||||
const LIBRARY_CATEGORY: Record<GameDataIconType, string> = {
|
||||
talent: 'Talents',
|
||||
brand_set: 'Brands',
|
||||
@@ -32,6 +34,7 @@ const LIBRARY_CATEGORY: Record<GameDataIconType, string> = {
|
||||
specialization: 'Specializations',
|
||||
specialization_ability: 'Specializations',
|
||||
specialization_node: 'Specializations',
|
||||
weapon_mod_slot: 'Weapon Mods',
|
||||
};
|
||||
const LIBRARY_LABEL: Record<GameDataIconType, string> = {
|
||||
talent: 'Talent',
|
||||
@@ -41,6 +44,7 @@ const LIBRARY_LABEL: Record<GameDataIconType, string> = {
|
||||
specialization: 'Specialization',
|
||||
specialization_ability: 'Specialization Ability',
|
||||
specialization_node: 'Specialization Tree Node',
|
||||
weapon_mod_slot: 'Weapon Mod Slot',
|
||||
};
|
||||
|
||||
export class GameDataIconValidationError extends Error {}
|
||||
@@ -255,6 +259,13 @@ async function libraryMetadata(
|
||||
],
|
||||
};
|
||||
}
|
||||
case 'weapon_mod_slot': {
|
||||
const definition = weaponModSlotDefinitionById(entityId);
|
||||
return {
|
||||
name: definition ? `${definition.name} Slot` : null,
|
||||
tags: ['weapon mod', 'slot', definition?.name],
|
||||
};
|
||||
}
|
||||
}
|
||||
return { name: null, tags: [] };
|
||||
}
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const { rmMock } = vi.hoisted(() => ({ rmMock: vi.fn() }));
|
||||
const { mkdirMock, rmMock, writeFileMock } = vi.hoisted(() => ({
|
||||
mkdirMock: vi.fn(),
|
||||
rmMock: vi.fn(),
|
||||
writeFileMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('node:fs/promises')>()),
|
||||
mkdir: mkdirMock,
|
||||
rm: rmMock,
|
||||
writeFile: writeFileMock,
|
||||
}));
|
||||
|
||||
import { GAME_ICON_TYPES } from './game-data-icons';
|
||||
import {
|
||||
GAME_DATA_RENDER_TYPES,
|
||||
ManagedImageDeletionError,
|
||||
applyGameDataRender,
|
||||
deleteManagedImage,
|
||||
processManagedImage,
|
||||
} from './managed-images';
|
||||
@@ -26,6 +33,16 @@ class FakeDatabase {
|
||||
prepare(sql: string) {
|
||||
return Promise.resolve({
|
||||
get: async (...args: unknown[]) => {
|
||||
if (
|
||||
sql.startsWith('SELECT * FROM managed_images') &&
|
||||
sql.includes('source_entity_type')
|
||||
) {
|
||||
return [...this.images.values()].find(
|
||||
(row) =>
|
||||
row.source_entity_type === args[0] &&
|
||||
row.source_entity_id === args[1],
|
||||
);
|
||||
}
|
||||
if (sql.startsWith('SELECT * FROM managed_images'))
|
||||
return this.images.get(String(args[0]));
|
||||
if (sql.startsWith('SELECT * FROM game_data_icons')) {
|
||||
@@ -42,7 +59,64 @@ class FakeDatabase {
|
||||
}
|
||||
if (sql.startsWith('DELETE FROM managed_images')) {
|
||||
const row = this.images.get(String(args[0]));
|
||||
if (row?.storage_key === args[1]) this.images.delete(String(args[0]));
|
||||
if (!sql.includes('storage_key') || row?.storage_key === args[1])
|
||||
this.images.delete(String(args[0]));
|
||||
return;
|
||||
}
|
||||
if (sql.startsWith('UPDATE managed_images SET')) {
|
||||
const row = this.images.get(String(args[7]));
|
||||
if (!row) throw new Error('Missing managed image for update');
|
||||
this.images.set(String(args[7]), {
|
||||
...row,
|
||||
category: args[0],
|
||||
tags_json: args[1],
|
||||
is_icon: 0,
|
||||
force_white: 0,
|
||||
original_filename: args[2],
|
||||
byte_size: args[3],
|
||||
width: args[4],
|
||||
height: args[5],
|
||||
updated_at: args[6],
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (sql.startsWith('INSERT INTO managed_images')) {
|
||||
const [
|
||||
id,
|
||||
name,
|
||||
category,
|
||||
tagsJson,
|
||||
isIcon,
|
||||
forceWhite,
|
||||
storageKey,
|
||||
originalFilename,
|
||||
byteSize,
|
||||
width,
|
||||
height,
|
||||
sourceType,
|
||||
sourceEntityId,
|
||||
createdBy,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
] = args;
|
||||
this.images.set(String(id), {
|
||||
id,
|
||||
name,
|
||||
category,
|
||||
tags_json: tagsJson,
|
||||
is_icon: isIcon,
|
||||
force_white: forceWhite,
|
||||
storage_key: storageKey,
|
||||
original_filename: originalFilename,
|
||||
byte_size: byteSize,
|
||||
width,
|
||||
height,
|
||||
source_entity_type: sourceType,
|
||||
source_entity_id: sourceEntityId,
|
||||
created_by: createdBy,
|
||||
created_at: createdAt,
|
||||
updated_at: updatedAt,
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw new Error(`Unexpected run: ${sql}`);
|
||||
@@ -210,3 +284,94 @@ describe('processManagedImage', () => {
|
||||
expect(metadata.height).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyGameDataRender', () => {
|
||||
beforeEach(() => {
|
||||
mkdirMock.mockReset().mockResolvedValue(undefined);
|
||||
rmMock.mockReset().mockResolvedValue(undefined);
|
||||
writeFileMock.mockReset().mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it.each(GAME_DATA_RENDER_TYPES)(
|
||||
'persists and returns authoritative %s render metadata',
|
||||
async (type) => {
|
||||
const database = new FakeDatabase();
|
||||
const result = await applyGameDataRender(
|
||||
database as never,
|
||||
type,
|
||||
sourceId,
|
||||
'Test Entity',
|
||||
{
|
||||
file: new File(['source'], 'source.png', { type: 'image/png' }),
|
||||
remove: false,
|
||||
processed: {
|
||||
output: Buffer.from('processed-render'),
|
||||
width: 1000,
|
||||
height: 1000,
|
||||
},
|
||||
},
|
||||
'operator-1',
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
original_filename: 'source.png',
|
||||
byte_size: Buffer.byteLength('processed-render'),
|
||||
width: 1000,
|
||||
height: 1000,
|
||||
source_entity_type: `${type}_render`,
|
||||
source_entity_id: sourceId,
|
||||
created_by: 'operator-1',
|
||||
created_at: expect.any(String),
|
||||
updated_at: expect.any(String),
|
||||
});
|
||||
expect(writeFileMock).toHaveBeenCalledOnce();
|
||||
},
|
||||
);
|
||||
|
||||
it('returns refreshed replacement metadata without rewriting creator provenance', async () => {
|
||||
const database = new FakeDatabase();
|
||||
database.images.set(imageId, {
|
||||
id: imageId,
|
||||
name: 'Ability render',
|
||||
category: 'Specializations',
|
||||
tags_json: '[]',
|
||||
is_icon: 0,
|
||||
force_white: 0,
|
||||
storage_key: `library-${imageId}.webp`,
|
||||
original_filename: 'old.png',
|
||||
byte_size: 3,
|
||||
width: 100,
|
||||
height: 100,
|
||||
source_entity_type: 'specialization_ability_render',
|
||||
source_entity_id: sourceId,
|
||||
created_by: 'original-operator',
|
||||
created_at: '2026-01-01T00:00:00.000Z',
|
||||
updated_at: '2026-01-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
const result = await applyGameDataRender(
|
||||
database as never,
|
||||
'specialization_ability',
|
||||
sourceId,
|
||||
'Ability',
|
||||
{
|
||||
file: new File(['new'], 'new.png', { type: 'image/png' }),
|
||||
remove: false,
|
||||
processed: {
|
||||
output: Buffer.from('replacement'),
|
||||
width: 1000,
|
||||
height: 1000,
|
||||
},
|
||||
},
|
||||
'replacement-operator',
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
id: imageId,
|
||||
original_filename: 'new.png',
|
||||
byte_size: Buffer.byteLength('replacement'),
|
||||
created_by: 'original-operator',
|
||||
created_at: '2026-01-01T00:00:00.000Z',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,7 +41,7 @@ export type GameDataRenderType = (typeof GAME_DATA_RENDER_TYPES)[number];
|
||||
const MAX_PIXELS = 24_000_000;
|
||||
const ID_PATTERN = /^[0-9a-f-]{36}$/i;
|
||||
const KEY_PATTERN =
|
||||
/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i;
|
||||
/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node|weapon_mod_slot)-[0-9a-f-]{36}\.webp$/i;
|
||||
|
||||
export class ManagedImageValidationError extends Error {}
|
||||
export class ManagedImageDeletionError extends Error {}
|
||||
@@ -250,7 +250,7 @@ export async function applyGameDataRender(
|
||||
);
|
||||
await (
|
||||
await database.prepare(`UPDATE managed_images SET category=?,tags_json=?,is_icon=0,force_white=0,
|
||||
original_filename=?,byte_size=?,width=?,height=?,created_by=?,updated_at=? WHERE id=?`)
|
||||
original_filename=?,byte_size=?,width=?,height=?,updated_at=? WHERE id=?`)
|
||||
).run(
|
||||
category[type],
|
||||
tags,
|
||||
@@ -258,19 +258,14 @@ export async function applyGameDataRender(
|
||||
processed.output.length,
|
||||
processed.width,
|
||||
processed.height,
|
||||
actorId,
|
||||
now,
|
||||
existing.id,
|
||||
);
|
||||
return {
|
||||
...existing,
|
||||
category: category[type],
|
||||
tags_json: tags,
|
||||
byte_size: processed.output.length,
|
||||
width: processed.width,
|
||||
height: processed.height,
|
||||
updated_at: now,
|
||||
};
|
||||
return (
|
||||
(await (
|
||||
await database.prepare('SELECT * FROM managed_images WHERE id=?')
|
||||
).get(existing.id)) ?? null
|
||||
);
|
||||
}
|
||||
const id = randomUUID();
|
||||
const storageKey = await saveManagedImageFile(id, processed.output);
|
||||
@@ -298,20 +293,11 @@ export async function applyGameDataRender(
|
||||
now,
|
||||
now,
|
||||
);
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
category: category[type],
|
||||
tags_json: tags,
|
||||
is_icon: 0,
|
||||
storage_key: storageKey,
|
||||
byte_size: processed.output.length,
|
||||
width: processed.width,
|
||||
height: processed.height,
|
||||
source_entity_type: sourceType,
|
||||
source_entity_id: entityId,
|
||||
updated_at: now,
|
||||
};
|
||||
return (
|
||||
(await (
|
||||
await database.prepare('SELECT * FROM managed_images WHERE id=?')
|
||||
).get(id)) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteManagedImage(database: Database, id: string) {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { Database } from '@tursodatabase/database';
|
||||
import {
|
||||
MOD_SECONDARY_QUALITIES,
|
||||
normalizeModSecondaryPool,
|
||||
type ModSecondaryRange,
|
||||
} from '$lib/mods/secondary-pool';
|
||||
|
||||
export type ModSecondaryPoolKind = 'weapon' | 'gear';
|
||||
|
||||
function tables(kind: ModSecondaryPoolKind) {
|
||||
return {
|
||||
options: `${kind}_mod_secondary_options`,
|
||||
values: `${kind}_mod_secondary_quality_values`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getModSecondaryPool(
|
||||
database: Database,
|
||||
kind: ModSecondaryPoolKind,
|
||||
): Promise<ModSecondaryRange[]> {
|
||||
const table = tables(kind);
|
||||
const [options, values] = await Promise.all([
|
||||
(
|
||||
await database.prepare(`SELECT stat_attribute_id FROM ${table.options}
|
||||
ORDER BY stat_attribute_id`)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(`SELECT * FROM ${table.values}
|
||||
ORDER BY stat_attribute_id,quality`)
|
||||
).all(),
|
||||
]);
|
||||
|
||||
const pool = options.map((option) => {
|
||||
const optionValues = values.filter(
|
||||
(value) => value.stat_attribute_id === option.stat_attribute_id,
|
||||
);
|
||||
if (
|
||||
optionValues.length !== MOD_SECONDARY_QUALITIES.length ||
|
||||
MOD_SECONDARY_QUALITIES.some(
|
||||
(quality) => !optionValues.some((value) => value.quality === quality),
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`${kind} Mod secondary Attribute ${String(option.stat_attribute_id)} does not have a complete quality profile.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
statId: String(option.stat_attribute_id),
|
||||
values: Object.fromEntries(
|
||||
optionValues.map((value) => [
|
||||
String(value.quality),
|
||||
{
|
||||
level1Min: Number(value.level_1_min),
|
||||
level1Max: Number(value.level_1_max),
|
||||
level20Min: Number(value.level_20_min),
|
||||
level20Max: Number(value.level_20_max),
|
||||
},
|
||||
]),
|
||||
) as ModSecondaryRange['values'],
|
||||
};
|
||||
});
|
||||
if (!pool.length) return [];
|
||||
const normalized = normalizeModSecondaryPool(pool);
|
||||
if (normalized.error) {
|
||||
throw new Error(`${kind} Mod secondary pool: ${normalized.error}`);
|
||||
}
|
||||
return normalized.pool;
|
||||
}
|
||||
|
||||
export async function replaceModSecondaryPool(
|
||||
database: Database,
|
||||
kind: ModSecondaryPoolKind,
|
||||
pool: ModSecondaryRange[],
|
||||
) {
|
||||
const table = tables(kind);
|
||||
await database.exec(`DELETE FROM ${table.values}`);
|
||||
await database.exec(`DELETE FROM ${table.options}`);
|
||||
for (const range of pool) {
|
||||
await (
|
||||
await database.prepare(
|
||||
`INSERT INTO ${table.options}(stat_attribute_id) VALUES(?)`,
|
||||
)
|
||||
).run(range.statId);
|
||||
for (const quality of MOD_SECONDARY_QUALITIES) {
|
||||
const values = range.values[quality];
|
||||
await (
|
||||
await database.prepare(`INSERT INTO ${table.values}
|
||||
(stat_attribute_id,quality,level_1_min,level_1_max,level_20_min,level_20_max)
|
||||
VALUES(?,?,?,?,?,?)`)
|
||||
).run(
|
||||
range.statId,
|
||||
quality,
|
||||
values.level1Min,
|
||||
values.level1Max,
|
||||
values.level20Min,
|
||||
values.level20Max,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { connect } from '@tursodatabase/database';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getOsProtocolProfileAttributeIds,
|
||||
type OsProtocolAttributeType,
|
||||
} from './os-protocol-profiles';
|
||||
|
||||
const databases: Array<Awaited<ReturnType<typeof connect>>> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(databases.splice(0).map((database) => database.close()));
|
||||
});
|
||||
|
||||
async function databaseWithAttributes() {
|
||||
const database = await connect(':memory:');
|
||||
databases.push(database);
|
||||
await database.exec(`CREATE TABLE stat_attributes (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
unit TEXT NOT NULL,
|
||||
active INTEGER NOT NULL
|
||||
)`);
|
||||
for (const [id, name, unit] of [
|
||||
['firepower-flat', 'Firepower', ''],
|
||||
['firepower-percent', 'Firepower', '%'],
|
||||
['toughness-flat', 'Toughness', ''],
|
||||
['toughness-percent', 'Toughness', '%'],
|
||||
['engineering-flat', 'Engineering', ''],
|
||||
['weapon-damage-flat', 'Weapon Damage', ''],
|
||||
['weapon-damage-percent', 'Weapon Damage', '%'],
|
||||
['skill-damage-percent', 'Skill Damage', '%'],
|
||||
['health-flat', 'Health', ''],
|
||||
]) {
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO stat_attributes(id,name,unit,active) VALUES(?,?,?,1)',
|
||||
)
|
||||
).run(id, name, unit);
|
||||
}
|
||||
return database;
|
||||
}
|
||||
|
||||
describe('OS Protocol profile Attributes', () => {
|
||||
it.each([
|
||||
[
|
||||
'Firepower',
|
||||
{
|
||||
Core: 'firepower-flat',
|
||||
Damage: 'weapon-damage-flat',
|
||||
Health: 'health-flat',
|
||||
},
|
||||
],
|
||||
[
|
||||
'Toughness',
|
||||
{
|
||||
Core: 'toughness-flat',
|
||||
Damage: 'weapon-damage-flat',
|
||||
Health: 'health-flat',
|
||||
},
|
||||
],
|
||||
[
|
||||
'Engineering',
|
||||
{
|
||||
Core: 'engineering-flat',
|
||||
Damage: 'skill-damage-percent',
|
||||
Health: 'health-flat',
|
||||
},
|
||||
],
|
||||
] as const)('resolves the exact %s role rows', async (type, expected) => {
|
||||
const database = await databaseWithAttributes();
|
||||
|
||||
await expect(
|
||||
getOsProtocolProfileAttributeIds(
|
||||
database,
|
||||
type as OsProtocolAttributeType,
|
||||
),
|
||||
).resolves.toEqual(expected);
|
||||
});
|
||||
|
||||
it('reports a missing required role without selecting a similarly named row', async () => {
|
||||
const database = await databaseWithAttributes();
|
||||
await (
|
||||
await database.prepare(
|
||||
"DELETE FROM stat_attributes WHERE id='weapon-damage-flat'",
|
||||
)
|
||||
).run();
|
||||
|
||||
await expect(
|
||||
getOsProtocolProfileAttributeIds(database, 'Firepower'),
|
||||
).resolves.toEqual({
|
||||
Core: 'firepower-flat',
|
||||
Damage: null,
|
||||
Health: 'health-flat',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Database } from '@tursodatabase/database';
|
||||
|
||||
export const OS_PROTOCOL_PROFILE_ATTRIBUTE_SPECS = {
|
||||
Firepower: {
|
||||
Core: { name: 'Firepower', unit: '' },
|
||||
Damage: { name: 'Weapon Damage', unit: '' },
|
||||
Health: { name: 'Health', unit: '' },
|
||||
},
|
||||
Toughness: {
|
||||
Core: { name: 'Toughness', unit: '' },
|
||||
Damage: { name: 'Weapon Damage', unit: '' },
|
||||
Health: { name: 'Health', unit: '' },
|
||||
},
|
||||
Engineering: {
|
||||
Core: { name: 'Engineering', unit: '' },
|
||||
Damage: { name: 'Skill Damage', unit: '%' },
|
||||
Health: { name: 'Health', unit: '' },
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type OsProtocolAttributeType =
|
||||
keyof typeof OS_PROTOCOL_PROFILE_ATTRIBUTE_SPECS;
|
||||
export type OsProtocolSecondaryRole = 'Core' | 'Damage' | 'Health';
|
||||
|
||||
export async function getOsProtocolProfileAttributeIds(
|
||||
database: Database,
|
||||
attributeType: OsProtocolAttributeType,
|
||||
) {
|
||||
const specs = OS_PROTOCOL_PROFILE_ATTRIBUTE_SPECS[attributeType];
|
||||
const entries = await Promise.all(
|
||||
(
|
||||
Object.entries(specs) as Array<
|
||||
[OsProtocolSecondaryRole, { name: string; unit: string }]
|
||||
>
|
||||
).map(async ([role, spec]) => {
|
||||
const row = await (
|
||||
await database.prepare(
|
||||
'SELECT id FROM stat_attributes WHERE active=1 AND name=? COLLATE NOCASE AND unit=?',
|
||||
)
|
||||
).get(spec.name, spec.unit);
|
||||
return [role, row ? String(row.id) : null] as const;
|
||||
}),
|
||||
);
|
||||
return Object.fromEntries(entries) as Record<
|
||||
OsProtocolSecondaryRole,
|
||||
string | null
|
||||
>;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user