Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e67c3d4cdb | ||
|
|
4777a3abd5 | ||
|
|
46f64d37b6 | ||
|
|
7ca6cb8458 | ||
|
|
57068b61cd |
@@ -41,6 +41,10 @@ ORIGIN=https://dev.shd.build
|
|||||||
# accessing development directly at http://localhost:5173.
|
# accessing development directly at http://localhost:5173.
|
||||||
PASSKEY_RP_ID=shd.build
|
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.
|
# SMTP delivery for email verification and magic-link authentication.
|
||||||
# Port 465 normally uses SMTP_SECURE=true. Port 587 normally uses false and STARTTLS.
|
# Port 465 normally uses SMTP_SECURE=true. Port 587 normally uses false and STARTTLS.
|
||||||
SMTP_HOST=
|
SMTP_HOST=
|
||||||
|
|||||||
@@ -32,8 +32,23 @@ npm run db:seed-users
|
|||||||
|
|
||||||
The seed creates `Aewyn` and `Quartermaster` with the standard user role. It
|
The seed creates `Aewyn` and `Quartermaster` with the standard user role. It
|
||||||
generates and displays strong temporary passwords once, then stores only salted
|
generates and displays strong temporary passwords once, then stores only salted
|
||||||
Argon2id hashes. The separate bootstrap policy promotes the first authenticated
|
Argon2id hashes. `DEFAULT_ADMIN_PASSWORD` is the password variable name for
|
||||||
account when no administrator exists.
|
`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
|
## 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_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 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 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 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 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);
|
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_ended_at TEXT,
|
||||||
session_end_reason TEXT,
|
session_end_reason TEXT,
|
||||||
sensitive_purged_at 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
|
created_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS user_emails (
|
CREATE TABLE IF NOT EXISTS user_emails (
|
||||||
@@ -643,6 +645,60 @@ CREATE TABLE IF NOT EXISTS deleted_accounts (
|
|||||||
reason TEXT
|
reason TEXT
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS login_events_user_created_idx ON login_events(user_id, created_at DESC);
|
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-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-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');
|
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_min REAL,
|
||||||
weapon_max REAL,
|
weapon_max REAL,
|
||||||
weapon_reference_level INTEGER NOT NULL DEFAULT 50 CHECK(weapon_reference_level BETWEEN 1 AND 50),
|
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,
|
cap_value REAL,
|
||||||
active INTEGER NOT NULL DEFAULT 1,
|
active INTEGER NOT NULL DEFAULT 1,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_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((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((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)
|
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);
|
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_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),
|
delivery_duration_seconds REAL CHECK(delivery_duration_seconds IS NULL OR delivery_duration_seconds>0),
|
||||||
targeting_json TEXT,
|
targeting_json TEXT,
|
||||||
|
probability_percent REAL CHECK(probability_percent IS NULL OR (probability_percent>=0 AND probability_percent<=100)),
|
||||||
min_state_value REAL,
|
min_state_value REAL,
|
||||||
max_state_value REAL,
|
max_state_value REAL,
|
||||||
description_slot INTEGER CHECK(description_slot IS NULL OR description_slot>=1),
|
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)
|
CHECK(max_value >= min_value)
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS skill_chip_primary_options (
|
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 PRIMARY KEY REFERENCES stat_attributes(id)
|
||||||
stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id),
|
);
|
||||||
PRIMARY KEY(skill_chip_set_id, stat_attribute_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 (
|
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 PRIMARY KEY REFERENCES stat_attributes(id)
|
||||||
stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id),
|
);
|
||||||
PRIMARY KEY(skill_chip_set_id, stat_attribute_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 (
|
CREATE TABLE IF NOT EXISTS skill_chip_talents (
|
||||||
id TEXT PRIMARY KEY,
|
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)
|
CHECK(primary_level_20_max>=primary_level_1_max)
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS weapon_mod_secondary_options (
|
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 PRIMARY KEY REFERENCES stat_attributes(id)
|
||||||
stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id),
|
|
||||||
PRIMARY KEY(weapon_mod_id,stat_attribute_id)
|
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS weapon_mod_secondary_quality_values (
|
CREATE TABLE IF NOT EXISTS weapon_mod_secondary_quality_values (
|
||||||
weapon_mod_id TEXT NOT NULL,
|
|
||||||
stat_attribute_id TEXT NOT NULL,
|
stat_attribute_id TEXT NOT NULL,
|
||||||
quality TEXT NOT NULL CHECK(quality IN ('Standard','Specialized','Superior','High-end')),
|
quality TEXT NOT NULL CHECK(quality IN ('Specialized','Superior','High-end')),
|
||||||
min_value REAL NOT NULL,
|
level_1_min REAL NOT NULL,
|
||||||
max_value REAL NOT NULL,
|
level_1_max REAL NOT NULL,
|
||||||
PRIMARY KEY(weapon_mod_id,stat_attribute_id,quality),
|
level_20_min REAL NOT NULL,
|
||||||
FOREIGN KEY(weapon_mod_id,stat_attribute_id) REFERENCES weapon_mod_secondary_options(weapon_mod_id,stat_attribute_id) ON DELETE CASCADE,
|
level_20_max REAL NOT NULL,
|
||||||
CHECK(max_value>=min_value)
|
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 (
|
CREATE TABLE IF NOT EXISTS weapon_mod_exotic_attributes (
|
||||||
id TEXT PRIMARY KEY,
|
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)
|
CHECK(primary_level_20_max>=primary_level_1_max)
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS gear_mod_secondary_options (
|
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 PRIMARY KEY REFERENCES stat_attributes(id)
|
||||||
stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id),
|
|
||||||
PRIMARY KEY(gear_mod_id,stat_attribute_id)
|
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS gear_mod_secondary_quality_values (
|
CREATE TABLE IF NOT EXISTS gear_mod_secondary_quality_values (
|
||||||
gear_mod_id TEXT NOT NULL,
|
|
||||||
stat_attribute_id TEXT NOT NULL,
|
stat_attribute_id TEXT NOT NULL,
|
||||||
quality TEXT NOT NULL CHECK(quality IN ('Standard','Specialized','Superior','High-end')),
|
quality TEXT NOT NULL CHECK(quality IN ('Specialized','Superior','High-end')),
|
||||||
min_value REAL NOT NULL,
|
level_1_min REAL NOT NULL,
|
||||||
max_value REAL NOT NULL,
|
level_1_max REAL NOT NULL,
|
||||||
PRIMARY KEY(gear_mod_id,stat_attribute_id,quality),
|
level_20_min REAL NOT NULL,
|
||||||
FOREIGN KEY(gear_mod_id,stat_attribute_id) REFERENCES gear_mod_secondary_options(gear_mod_id,stat_attribute_id) ON DELETE CASCADE,
|
level_20_max REAL NOT NULL,
|
||||||
CHECK(max_value>=min_value)
|
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 (
|
CREATE TABLE IF NOT EXISTS specialization_progression_config (
|
||||||
id TEXT PRIMARY KEY CHECK(id='default'),
|
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)
|
CHECK(charge_policy<>'none' OR charge_count=1)
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS specialization_abilities_specialization_idx ON specialization_abilities(specialization_id,name);
|
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 (
|
CREATE TABLE IF NOT EXISTS specialization_ability_effects (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
ability_id TEXT NOT NULL REFERENCES specialization_abilities(id) ON DELETE CASCADE,
|
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)
|
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');
|
VALUES('calculation-stage-cycle-1','calculation-revision-1','firing_cycle','Firing cycle','firing_cycle',9,'last');
|
||||||
CREATE TABLE IF NOT EXISTS game_data_icons (
|
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,
|
entity_id TEXT NOT NULL,
|
||||||
storage_key TEXT NOT NULL UNIQUE,
|
storage_key TEXT NOT NULL UNIQUE,
|
||||||
byte_size INTEGER NOT NULL CHECK(byte_size > 0),
|
byte_size INTEGER NOT NULL CHECK(byte_size > 0),
|
||||||
|
|||||||
@@ -140,6 +140,10 @@ describe('container runtime startup', () => {
|
|||||||
writeFile(join(temporaryRoot, 'chown'), ownershipStub, { mode: 0o755 }),
|
writeFile(join(temporaryRoot, 'chown'), ownershipStub, { mode: 0o755 }),
|
||||||
writeExport(appExport, [
|
writeExport(appExport, [
|
||||||
{ key: 'APP_ORIGIN', value: 'https://exported.invalid' },
|
{ 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_ID', value: 'exported-discord-client' },
|
||||||
{ key: 'DISCORD_CLIENT_SECRET', value: 'exported-discord-secret' },
|
{ key: 'DISCORD_CLIENT_SECRET', value: 'exported-discord-secret' },
|
||||||
]),
|
]),
|
||||||
@@ -218,6 +222,8 @@ describe('container runtime startup', () => {
|
|||||||
);
|
);
|
||||||
expect(childEnvironment).toMatchObject({
|
expect(childEnvironment).toMatchObject({
|
||||||
APP_ORIGIN: 'https://stage.shd.build',
|
APP_ORIGIN: 'https://stage.shd.build',
|
||||||
|
MFA_TOTP_ENCRYPTION_KEYS:
|
||||||
|
'primary:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
||||||
DISCORD_CLIENT_ID: 'exported-discord-client',
|
DISCORD_CLIENT_ID: 'exported-discord-client',
|
||||||
DISCORD_CLIENT_SECRET: 'exported-discord-secret',
|
DISCORD_CLIENT_SECRET: 'exported-discord-secret',
|
||||||
ORIGIN: 'https://stage.shd.build',
|
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
|
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 current Git tree does not revoke values stored in Git history.
|
||||||
|
|
||||||
The staging container disables default-user seeding. On a brand-new staging
|
## Authentication bootstrap and MFA secrets
|
||||||
database, the existing bootstrap policy grants Admin to the first real account
|
|
||||||
that signs in instead of writing generated credentials into deployment logs.
|
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
|
## 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,
|
backup, and refuses to replace a production database containing users, builds,
|
||||||
audit events, or proposals. Remove the remote host copy after verification.
|
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
|
### Promote one News article
|
||||||
|
|
||||||
Export a locally authored News article by ID or slug while the development
|
Export a locally authored News article by ID or slug while the development
|
||||||
|
|||||||
+161
-52
@@ -341,17 +341,36 @@ squeezed beside long module headings on mobile.
|
|||||||
### Command Grid builder
|
### Command Grid builder
|
||||||
|
|
||||||
The production builder uses Command Grid as its loadout workbench. Keep the
|
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
|
entire build visible in a compact matrix while one connected editor configures
|
||||||
selected cell. The full-width Specialization and Focus cell is the root choice.
|
the selected cell. The editor uses two lanes when its container has enough
|
||||||
Its three supplied abilities appear directly below it, followed by two weapons,
|
width. Equipment identity and base values occupy the first lane; allocations,
|
||||||
six gear positions, and one full-width OS Protocol.
|
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
|
Matrix cells show the slot and equipped item at rest. The selected cell reveals
|
||||||
Sets, or Skill Chips. Do not replace those choices with configuration counts.
|
its compact Talent, Attribute, Brand Set, or Skill Chip summary. This
|
||||||
Selecting an ability opens its three attached Skill Chip positions. Selecting
|
progressive disclosure keeps the matrix scannable without replacing configured
|
||||||
equipment opens the searchable real-data catalog and its compatible
|
choices with counts. Selecting an ability opens its three attached Skill Chip
|
||||||
configuration controls. Do not display a prototype option that the current
|
positions. Selecting equipment opens the searchable real-data catalog and its
|
||||||
loadout schema cannot save.
|
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.
|
||||||
|
|
||||||
|
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
|
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
|
matrix cell with a compact red text indicator and status rail, then let the cell
|
||||||
@@ -361,20 +380,19 @@ and Skill DPS, encounter assumptions, activation profiles, rotations, and
|
|||||||
simulation as `COMING SOON`; they do not report calculation-profile blockers as
|
simulation as `COMING SOON`; they do not report calculation-profile blockers as
|
||||||
current player errors.
|
current player errors.
|
||||||
|
|
||||||
The builder uses three workbench columns on wide screens, moves the live summary
|
The builder uses two primary workbench columns on wide screens: the compact
|
||||||
below the matrix and editor at intermediate widths, and stacks all three regions
|
matrix and the two-lane editor. The live summary spans both columns below them.
|
||||||
on narrow screens. Every grid track and editor child must accept content
|
Narrow screens stack the matrix, editor, and summary. Every grid track and
|
||||||
shrinkage. At 560px and below, stack attribute picker/value pairs and section
|
editor child must accept content shrinkage. At 560px and below, stack Attribute
|
||||||
headings; keep the command actions in two equal columns. Validation markers use
|
picker/value pairs and section headings; keep the command actions in two equal
|
||||||
short visible labels while their full messages remain available to assistive
|
columns. Validation markers use short visible labels while their full messages
|
||||||
technology and pointer users.
|
remain available to assistive technology and pointer users.
|
||||||
|
|
||||||
Use `--shd-builder-canvas` for recessed equipment fields. Use
|
Use `--shd-builder-canvas` for recessed equipment fields. Use
|
||||||
`--shd-builder-selection` with `--shd-builder-selection-border` for the selected
|
`--shd-builder-selection` with `--shd-builder-selection-border` for the selected
|
||||||
cell. Preserve button semantics, `aria-pressed`, focus-visible outlines, pressed
|
cell. Preserve button semantics, `aria-pressed`, focus-visible outlines, pressed
|
||||||
feedback, and textual readiness states. At narrower desktop widths, keep the
|
feedback, and textual readiness states. On mobile, stack the matrix, editor, and
|
||||||
matrix and editor together and move live results below them. On mobile, stack
|
live results in that order.
|
||||||
the matrix, editor, and live results in that order.
|
|
||||||
|
|
||||||
The command header contains the required build name, local and account save
|
The command header contains the required build name, local and account save
|
||||||
actions, account visibility, and optional sharing notes. Sharing notes include a
|
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 data console
|
||||||
|
|
||||||
Skill Chip editing uses the dense ISAC staged-definition pattern: identity and
|
Place two shared configuration cards above the Skill Chip definition registry.
|
||||||
icon, 2-piece numeric bonus, mutually exclusive primary and secondary roll
|
The `SHARED PRIMARY PROFILE` card contains the fixed Skill Duration, Skill
|
||||||
pools, then 3-piece talent. Superior blue and High-end yellow identify the
|
Radius, Skill Cooldown Recovery, and Skill Intensity rows, with direct minimum
|
||||||
supported qualities; reserve orange for actions and the current interactive
|
and maximum fields for Standard, Specialized, Superior, and High-end. Its
|
||||||
state. A four-step segmented preview shows the linear zero-to-three High-end
|
summary shows the configured count for each quality and the `NOT YET DEFINED`
|
||||||
interpolation without duplicate values. At narrow widths, stack roll-pool and
|
calculation state. The `SHARED SECONDARY POOL` card contains repeatable
|
||||||
talent columns, use a two-by-two preview grid, and retain 44-pixel targets.
|
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
|
### ISAC record identity controls
|
||||||
|
|
||||||
@@ -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
|
endpoints as level-50 values. Weapon Talent 1 and Talent 2 pools use separate
|
||||||
clickable icon rails and the standard Talent quick view.
|
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
|
Record cards may end with contextual actions that connect related public
|
||||||
registries. Actions use the compact orange tactical-button treatment and encode
|
registries. Actions use the compact orange tactical-button treatment and encode
|
||||||
their destination filter in the URL. Brand cards provide a “find Gear” action
|
their destination filter in the URL. Brand cards provide a “find Gear” action
|
||||||
@@ -1970,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
|
required slot counts in text. Disabled tiers remain visibly unavailable and must
|
||||||
not submit slot assignments.
|
not submit slot assignments.
|
||||||
|
|
||||||
Quality-scaled registry editors use a compact matrix when the same numeric shape
|
Regular Weapon Mod definitions use a compact Level 1 and Level 20 primary-range
|
||||||
repeats across all four quality levels. Apply global quality colors only to the
|
matrix for Standard, Specialized, Superior, and High-end quality. Apply global
|
||||||
quality labels, retain textual quality names, and keep numeric fields neutral.
|
quality colors only to the quality labels, retain textual quality names, and
|
||||||
On narrow screens the matrix may scroll horizontally while its surrounding
|
keep numeric fields neutral. On narrow screens the matrix may scroll
|
||||||
editor and previews stack.
|
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
|
Configure one global secondary Attribute pool above the Weapon Mod registry.
|
||||||
once rather than rendering two duplicated selectors. Weapon Mods use a
|
Each repeatable row contains one searchable Attribute reference and Level 1 and
|
||||||
repeatable secondary-attribute row: one searchable attribute reference plus four
|
Level 20 minimum and maximum fields for Specialized, Superior, and High-end.
|
||||||
quality range pairs. Show that the builder selects two distinct options from the
|
Values between those endpoints use linear interpolation. Do not render Standard
|
||||||
pool. Primary core type is likewise builder-selected and is displayed as the
|
range fields because Standard has no secondary slot. State the fixed capacity in
|
||||||
three always-available core icons rather than a persisted ISAC choice.
|
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
|
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
|
and the standard pressed, focus-visible, and selected states. Selecting it must
|
||||||
@@ -1993,12 +2057,20 @@ state panel visible so the change in data semantics is unmistakable.
|
|||||||
|
|
||||||
### Gear Mod registry
|
### Gear Mod registry
|
||||||
|
|
||||||
Gear Mods reuse the compact quality matrix, level preview, and pooled
|
Gear Mods reuse the compact Level 1 and Level 20 primary-range matrix and level
|
||||||
secondary-attribute rows established by Weapon Mods. Do not render exotic,
|
preview established by regular Weapon Mods. Do not render exotic,
|
||||||
weapon-category, slot-type, or Mod Effect controls in this simpler editor.
|
weapon-category, slot-type, or Mod Effect controls in this simpler editor.
|
||||||
Present the five primary options as an always-visible informational band because
|
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
|
the build instance selects the primary; the canonical definition does not store
|
||||||
canonical definition.
|
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:
|
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
|
`1 MOD SLOT` for T0 and `2 MOD SLOTS` for T1/T2. On narrow screens the identity
|
||||||
@@ -2229,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
|
tree rows are contiguous, at least one paid node exists, and the tree uses the
|
||||||
configured available point total.
|
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
|
Compose focus identity from uploaded specialization artwork and the canonical
|
||||||
Firepower, Toughness, or Engineering icon. Keep the specialization art primary
|
Firepower, Toughness, or Engineering icon. Keep the specialization art primary
|
||||||
and place the attribute mark in a smaller corner badge. Mirror ability slots 1
|
and place the attribute mark in a smaller corner badge. Mirror ability slots 1
|
||||||
@@ -2325,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
|
field and describes the configured value as the total delivered across that
|
||||||
duration; switching back to instant clears the duration.
|
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
|
Action Family recipes are optional authoring starters. Present the recipe
|
||||||
catalog as a searchable assignment with only the parameters required by the
|
catalog as a searchable assignment with only the parameters required by the
|
||||||
selected family. `ADD RECIPE BRANCH` copies typed branches into the current
|
selected family. `ADD RECIPE BRANCH` copies typed branches into the current
|
||||||
@@ -2511,10 +2600,11 @@ conditional Talent outcomes, weapon and Skill DPS, encounter assumptions,
|
|||||||
activation profiles, rotations, and simulation behind explicit `COMING SOON`
|
activation profiles, rotations, and simulation behind explicit `COMING SOON`
|
||||||
panels.
|
panels.
|
||||||
|
|
||||||
Loadout schema version 4 retains `maximum`, `custom`, conditional state,
|
Loadout schema version 6 retains `maximum`, `custom`, conditional state,
|
||||||
encounter-assumption, and per-ability activation fields for compatibility. The
|
encounter-assumption, and per-ability activation fields. Version 4 is obsolete
|
||||||
current player UI ignores those fields. When player simulation ships, one
|
prototype data rather than a compatibility format. The current player UI ignores
|
||||||
required two-option `SegmentedChoice` will use these visible labels:
|
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
|
- `MAXIMUM POTENTIAL` calculates a configured ceiling. It applies conditional
|
||||||
effects at peak state and treats selected abilities as firing on cooldown
|
effects at peak state and treats selected abilities as firing on cooldown
|
||||||
@@ -2565,8 +2655,17 @@ The builder presents Specialization and Focus as the first loadout decision. The
|
|||||||
Focus sets the primary Attribute, progression tree, and three ordered abilities.
|
Focus sets the primary Attribute, progression tree, and three ordered abilities.
|
||||||
Its identity combines Specialization artwork and the primary Attribute mark.
|
Its identity combines Specialization artwork and the primary Attribute mark.
|
||||||
Each ability panel contains three fixed Skill Chip positions. A chip picker
|
Each ability panel contains three fixed Skill Chip positions. A chip picker
|
||||||
shows definitions assigned to that ability and excludes definitions assigned
|
shows the ability's three definitions. When ISAC explicitly pairs that ability
|
||||||
elsewhere.
|
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
|
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
|
ranks remain visible with their lock state. The builder permits a row after the
|
||||||
@@ -2581,11 +2680,11 @@ enabled ability. Future Skill result cards will show activation count,
|
|||||||
single-target DPS, total-target DPS, and a combined summary. They will state
|
single-target DPS, total-target DPS, and a combined summary. They will state
|
||||||
that Skills can critically hit and multi-shot but cannot headshot.
|
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
|
encounter assumptions, and per-ability activation profiles. The builder accepts
|
||||||
only the current complete structure for compatibility. Current player pages
|
only the current complete structure. Current player pages ignore the simulation
|
||||||
ignore the simulation fields. Invalid local drafts are discarded; invalid server
|
fields. The builder discards version 4 local drafts; a backed-up database
|
||||||
records are removed through a backed-up database migration.
|
migration removes version 4 server records.
|
||||||
|
|
||||||
### Calculation revision controls
|
### Calculation revision controls
|
||||||
|
|
||||||
@@ -2650,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
|
confirmation names the owning entity type. ISAC clears that source icon
|
||||||
assignment before it removes the shared file. General library uploads retain the
|
assignment before it removes the shared file. General library uploads retain the
|
||||||
`ICON` or `IMAGE` state.
|
`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
|
- **Healing:** Count each healing event. A heal-over-time effect counts each
|
||||||
tick as a separate event.
|
tick as a separate event.
|
||||||
- **Extra Health gained:** Count every Extra Health grant, including a grant
|
- **Extra Health gained:** Count every Extra Health grant, including a grant
|
||||||
received while the Agent already has Extra Health. Use
|
received while the Agent already has Extra Health. Use `extra_health_granted`
|
||||||
`extra_health_granted` for the grant action.
|
for the grant action.
|
||||||
- **Extra Health state changed:** `extra_health_changed` is Agent-scoped. Emit
|
- **Extra Health state changed:** `extra_health_changed` is Agent-scoped. Emit
|
||||||
it when the Agent receives a grant, when the remaining amount changes, and
|
it when the Agent receives a grant, when the remaining amount changes, and
|
||||||
when it is depleted to zero. Every event requires the boolean
|
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
|
`values.currentExtraHealth`, which must agree with that status. This state
|
||||||
event is distinct from the `extra_health_granted` grant action.
|
event is distinct from the `extra_health_granted` grant action.
|
||||||
- **Reloading:** Trigger on reload completion when the weapon had at least one
|
- **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 to load. A canceled reload does not count. A weapon that loads one round
|
||||||
round at a time produces one completed-reload event for each round.
|
at a time produces one completed-reload event for each round.
|
||||||
- **Taking damage:** Require Health loss. A hit absorbed by Extra Health does
|
- **Taking damage:** Require Health loss. A hit absorbed by Extra Health does
|
||||||
not count as taking damage.
|
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
|
- The benefit remains active while the Agent has at least one persistent
|
||||||
deployed Skill object.
|
deployed Skill object.
|
||||||
- Destruction or timed expiration removes that object from the active count.
|
- Destruction or timed expiration removes that object from the active count. The
|
||||||
The benefit ends when no qualifying objects remain.
|
benefit ends when no qualifying objects remain.
|
||||||
- Instant-use Skills do not qualify.
|
- Instant-use Skills do not qualify.
|
||||||
- Multiple deployed Skill objects do not multiply the benefit.
|
- 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.
|
- Moving within 30 meters of an ally who is already downed qualifies.
|
||||||
- Each qualifying presence refreshes the ten-second duration.
|
- Each qualifying presence refreshes the ten-second duration.
|
||||||
- Leaving the radius or reviving the ally does not cancel the current
|
- Leaving the radius or reviving the ally does not cancel the current ten-second
|
||||||
ten-second duration.
|
duration.
|
||||||
- The wearer does not count as an ally.
|
- The wearer does not count as an ally.
|
||||||
|
|
||||||
## Emergency Retreat
|
## 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
|
round inserted by a weapon that reloads one round at a time. A canceled reload
|
||||||
does not qualify.
|
does not qualify.
|
||||||
|
|
||||||
Resolve the Extra Health amount from the Agent's displayed Firepower when
|
Resolve the Extra Health amount from the Agent's displayed Firepower when Second
|
||||||
Second Wind activates. Include all equipped-item and active-buff contributions
|
Wind activates. Include all equipped-item and active-buff contributions in that
|
||||||
in that Firepower value:
|
Firepower value:
|
||||||
|
|
||||||
- Tier I grants Extra Health equal to 600% of displayed Firepower.
|
- Tier I grants Extra Health equal to 600% of displayed Firepower.
|
||||||
- Tier II grants Extra Health equal to 840% 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
|
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
|
five-second duration and five-second cooldown start when it activates. Expiry
|
||||||
removes only the Second Wind contribution. At the five-second boundary, the
|
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.
|
prior grant can expire as a qualifying reload completion grants a new amount. If
|
||||||
If Second Wind supplies the Agent's only Extra Health, the old amount expires
|
Second Wind supplies the Agent's only Extra Health, the old amount expires and
|
||||||
and the new amount begins at that boundary.
|
the new amount begins at that boundary.
|
||||||
|
|
||||||
## Nothing to Lose
|
## Nothing to Lose
|
||||||
|
|
||||||
Nothing to Lose uses a forward-looking deployment window:
|
Nothing to Lose uses a forward-looking deployment window:
|
||||||
|
|
||||||
- Each Health-damage instance makes its own 50% activation roll. Damage
|
- Each Health-damage instance makes its own 50% activation roll. Damage absorbed
|
||||||
absorbed by Extra Health does not roll.
|
by Extra Health does not roll.
|
||||||
- A successful activation applies to every Skill deployed during the five
|
- A successful activation applies to every Skill deployed during the five
|
||||||
seconds after that damage instance. Several Skills can qualify during the
|
seconds after that damage instance. Several Skills can qualify during the same
|
||||||
same window.
|
window.
|
||||||
- Calculate each reduction from the Skill's total cooldown after equipment
|
- Calculate each reduction from the Skill's total cooldown after equipment
|
||||||
modifiers.
|
modifiers.
|
||||||
- A Skill can receive no more than three reductions during one cooldown cycle.
|
- 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.
|
||||||
|
|||||||
+212
-68
@@ -9,8 +9,8 @@ The team builds the project in vertical slices.
|
|||||||
data
|
data
|
||||||
- Auth.js-compatible server authentication with Discord OAuth and credentials
|
- Auth.js-compatible server authentication with Discord OAuth and credentials
|
||||||
providers
|
providers
|
||||||
- Argon2id password hashing, server-side sessions, CSRF protection, and role
|
- Argon2id password hashing, TOTP MFA with encrypted secrets, server-side
|
||||||
checks
|
sessions, CSRF protection, and role checks
|
||||||
- Browser `localStorage` only for anonymous build drafts
|
- Browser `localStorage` only for anonymous build drafts
|
||||||
|
|
||||||
## Product boundaries
|
## Product boundaries
|
||||||
@@ -41,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
|
password. Current work factors meet OWASP's 19 MiB / 2 iteration / single-lane
|
||||||
minimum profile. Successful login rehashes credentials with outdated parameters.
|
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
|
## Database runtime
|
||||||
|
|
||||||
The application uses Turso Database in-process through its native Node package.
|
The application uses Turso Database in-process through its native Node package.
|
||||||
@@ -244,10 +281,9 @@ does not become part of the canonical action replay.
|
|||||||
|
|
||||||
# Administrator bootstrap
|
# Administrator bootstrap
|
||||||
|
|
||||||
The authentication service promotes the first authenticated user to `admin` if
|
The first full administrator is created only through the offline flow documented
|
||||||
the database does not contain an administrator. Promotion uses one conditional
|
in [Authentication and MFA](#authentication-and-mfa). Sign-in never grants
|
||||||
database update so concurrent first sign-ins cannot both become administrators.
|
administrator authority or changes an account's roles.
|
||||||
Once an administrator exists, later sign-ins never alter roles.
|
|
||||||
|
|
||||||
# Database backup and restore
|
# Database backup and restore
|
||||||
|
|
||||||
@@ -431,11 +467,13 @@ reference.
|
|||||||
|
|
||||||
# OS Protocol progression
|
# OS Protocol progression
|
||||||
|
|
||||||
OS Protocols use dedicated normalized tables because their talent progression
|
OS Protocols use dedicated normalized tables because an assigned Talent has six
|
||||||
has six calculated states (base plus five upgrades) rather than the shared
|
calculated states: its base value plus five upgrades. Each protocol owns one
|
||||||
two-tier talent model. Each protocol owns one `os_protocol_primary_effects`
|
`os_protocol_primary_effects` record and may own one `os_protocol_talents`
|
||||||
record and one `os_protocol_talents` record. Each protocol Talent owns its
|
record. The optional Talent owns its mechanic program through the shared
|
||||||
mechanic program through the shared conditional-effect tables.
|
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
|
Dedicated profile tables normalize the three secondary attributes. One
|
||||||
`os_protocol_secondary_profiles` record exists for each configured quality +
|
`os_protocol_secondary_profiles` record exists for each configured quality +
|
||||||
@@ -460,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
|
eligibility requires both a category match and the slot to exist on the equipped
|
||||||
weapon at its selected tier.
|
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
|
Exotic Weapon Mods instead reference one `exotic_weapon_id`. They do not carry
|
||||||
regular category compatibility. The builder treats them as permanent equipment
|
regular category compatibility. The builder treats them as permanent equipment
|
||||||
and lets the user change only their level. They do not use quality profiles,
|
and lets the user change only their level. They do not use quality profiles,
|
||||||
@@ -469,16 +516,21 @@ values in `weapon_mod_exotic_attributes`; intermediate values use linear
|
|||||||
interpolation.
|
interpolation.
|
||||||
|
|
||||||
All regular Weapon Mods permit Firepower, Toughness, or Engineering as a
|
All regular Weapon Mods permit Firepower, Toughness, or Engineering as a
|
||||||
builder-time primary choice. `weapon_mod_quality_values` stores the shared
|
builder-time primary choice. `weapon_mod_quality_values` stores each
|
||||||
level-1 and level-20 primary roll endpoints for each quality; primary values use
|
definition's level-1 and level-20 primary roll endpoints for each quality;
|
||||||
linear interpolation from levels 1–20.
|
primary values use linear interpolation from levels 1–20.
|
||||||
|
|
||||||
`weapon_mod_secondary_options` is the shared pool from which a build selects two
|
`weapon_mod_secondary_options` stores one global secondary Attribute pool for
|
||||||
distinct secondary attributes. Every option owns a minimum and maximum at each
|
all regular Weapon Mods. Each Attribute has Level 1 and Level 20 minimum and
|
||||||
quality through `weapon_mod_secondary_quality_values`; secondary availability is
|
maximum ranges for Specialized, Superior, and High-end quality through
|
||||||
not tied to a numbered slot in canonical data. Mod Effects are triggerless, hold
|
`weapon_mod_secondary_quality_values`; values at intermediate levels use linear
|
||||||
one or two attribute references, and store fixed per-quality values in
|
interpolation. Secondary capacity is fixed by quality: Standard has zero,
|
||||||
`weapon_mod_effect_quality_values`.
|
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
|
### Gear Mods and tier slot capacity
|
||||||
|
|
||||||
@@ -488,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
|
those endpoints use linear interpolation. A build instance selects one primary
|
||||||
attribute from Firepower, Toughness, Engineering, Health, or Armor.
|
attribute from Firepower, Toughness, Engineering, Health, or Armor.
|
||||||
|
|
||||||
The two secondary slots draw distinct attributes from one shared option pool.
|
One global Gear Mod secondary Attribute pool serves every Gear Mod definition.
|
||||||
Each available secondary attribute stores its own minimum and maximum for all
|
Each Attribute has Level 1 and Level 20 minimum and maximum ranges for
|
||||||
four qualities. Gear tier determines capacity without per-item configuration: T0
|
Specialized, Superior, and High-end quality through
|
||||||
gear has one Gear Mod slot; T1 and T2 gear have two.
|
`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
|
The `gear_mods` and `gear_mod_quality_values` tables store individual definition
|
||||||
`gear_mod_secondary_quality_values` tables store canonical data. Successful
|
data. `gear_mod_secondary_options` and `gear_mod_secondary_quality_values` store
|
||||||
mutations use `gear_mod.created`, `gear_mod.updated`, `gear_mod.archived`, and
|
the family-wide pool. Pool updates append a
|
||||||
`gear_mod.unarchived` audit actions. Contributor submissions use the `gearmods`
|
`game_data/gear_mod_secondary_pool.updated` audit event. Individual definition
|
||||||
proposal module and remain non-canonical until approved by Oversight.
|
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 quality scaling
|
||||||
|
|
||||||
Skill Chip sets support Superior and High-end chip instances. Each set owns one
|
Each Skill Chip definition belongs to one specialization ability. Each set owns
|
||||||
2-piece bonus, distinct primary and secondary stat-option pools, and one 3-piece
|
one 2-piece bonus and may own one 3-piece Talent with a typed conditional rule.
|
||||||
Talent with a typed conditional rule. Primary and secondary options reference
|
ISAC keeps the complete Talent workspace, including its description and mechanic
|
||||||
active Attributes that define Skill Chip minimum and maximum roll values. A
|
program, inside one collapsible optional section.
|
||||||
configured chip rolls one primary and two distinct secondary stats from those
|
|
||||||
pools.
|
|
||||||
|
|
||||||
Each active Skill Chip definition belongs to one active specialization ability
|
All definitions use one fixed primary profile. The profile contains Skill
|
||||||
through `skill_chip_sets.specialization_ability_id`. An ability has capacity for
|
Duration, Skill Radius, Skill Cooldown Recovery, and Skill Intensity, with
|
||||||
three active definitions. The save and restore actions enforce that capacity,
|
direct minimum and maximum values for Standard, Specialized, Superior, and
|
||||||
and the schema requires the foreign key for each row. A specialization focus is
|
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
|
builder-ready only when each of its three assigned abilities has all three
|
||||||
active Skill Chip definitions. Skill Chip mutations refresh readiness for
|
active Skill Chip definitions. Skill Chip mutations refresh readiness for
|
||||||
focuses that reference both the previous and resulting ability.
|
focuses that reference both the previous and resulting ability.
|
||||||
|
|
||||||
Both set-bonus progression systems use the number of equipped High-end chips as
|
`specialization_ability_skill_chip_links` may pair two active abilities from the
|
||||||
the interpolation step. For `h` High-end chips, where `h` is zero through three,
|
same specialization. Operators create each pair explicitly; the application does
|
||||||
a configured minimum and maximum resolve as
|
not infer compatibility from ability names, focus membership, or shared
|
||||||
`minimum + (maximum − minimum) × h / 3`. Zero High-end chips therefore produce
|
specialization. The pair is bidirectional, and an ability may belong to only one
|
||||||
the minimum, while three produce the maximum. The editor stores endpoints and
|
pair. This permits compatible abilities such as two Tactical Link variants to
|
||||||
previews all four states, preventing duplicated tier data from diverging. The
|
share definitions without making unrelated pairs such as two Smart Cover
|
||||||
`game_data` audit stream captures Skill Chip mutations, owned relationships, and
|
variants compatible. Archiving either ability removes the pair; restoring an
|
||||||
safe icon metadata.
|
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
|
# Mechanic programs and loadout state
|
||||||
|
|
||||||
@@ -542,6 +638,9 @@ state, with sprinting taking precedence. Enabled Skill activation profiles emit
|
|||||||
the deployed ability. An `on_event` Skill modifier applies to that activation
|
the deployed ability. An `on_event` Skill modifier applies to that activation
|
||||||
until the ability's next deployment or the encounter ends.
|
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:
|
Each source supplies a fixed value-variant contract:
|
||||||
|
|
||||||
- Standard Talents require Tier I and Tier II values. Exotic Talents require
|
- Standard Talents require Tier I and Tier II values. Exotic Talents require
|
||||||
@@ -577,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
|
event registry publishes authoritative field metadata where branches need a
|
||||||
bounded contract. For those events, the mechanic editor derives its event-field
|
bounded contract. For those events, the mechanic editor derives its event-field
|
||||||
controls from that metadata. For example, `weapon_hit.values.headshot` is a
|
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
|
`extra_health_changed` is Agent-scoped. Normalized timeline producers emit it
|
||||||
when the Agent receives an Extra Health grant, when the remaining amount
|
when the Agent receives an Extra Health grant, when the remaining amount
|
||||||
@@ -612,7 +727,10 @@ The program graph uses three child tables:
|
|||||||
durations, intervals, or derived values. Mutations can set, add, consume,
|
durations, intervals, or derived values. Mutations can set, add, consume,
|
||||||
clear, derive, or transfer state. A mutation may bind a target-scoped pool to
|
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
|
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,
|
- `conditional_effect_outcomes` applies Attribute modifiers, direct damage,
|
||||||
restoration, resource costs, or markers. Each outcome names its target scope,
|
restoration, resource costs, or markers. Each outcome names its target scope,
|
||||||
value basis, optional resource or Attribute, combat timing, optional state
|
value basis, optional resource or Attribute, combat timing, optional state
|
||||||
@@ -621,15 +739,37 @@ The program graph uses three child tables:
|
|||||||
required by the owning source. The common value models are static values, live
|
required by the owning source. The common value models are static values, live
|
||||||
Attribute values, percentage-normalized Attribute values, event-field values,
|
Attribute values, percentage-normalized Attribute values, event-field values,
|
||||||
reducer output, source-defined scenario values, and OS Protocol upgrade
|
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.
|
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
|
The generic runtime in `src/lib/calculations/mechanic.ts` processes combat
|
||||||
events in timestamp order. Predicates decide whether an event enters a clause.
|
events in timestamp order. Predicates decide whether an event enters a clause.
|
||||||
Reducers convert event streams into activations or derived values. The runtime
|
Reducers convert event streams into activations or derived values. The runtime
|
||||||
applies state mutations, enforces pool bounds and expiry, emits outcomes in
|
applies state mutations, enforces pool bounds and expiry, emits outcomes in
|
||||||
phase order, and records telemetry for each activation.
|
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,
|
Each write to an expiring pool refreshes its timer. When a pool expires,
|
||||||
`on_exit` outcomes evaluate stack thresholds and per-stack expressions against
|
`on_exit` outcomes evaluate stack thresholds and per-stack expressions against
|
||||||
the final value before reset. The runtime records the bound target on emitted
|
the final value before reset. The runtime records the bound target on emitted
|
||||||
@@ -647,11 +787,13 @@ substitute zero.
|
|||||||
The read-only `combat.currentHealthPercent` reference divides resolved current
|
The read-only `combat.currentHealthPercent` reference divides resolved current
|
||||||
Health by resolved maximum Health and returns a value from 0 through 100. The
|
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
|
runtime derives it when a predicate is evaluated instead of storing a second
|
||||||
Health value that could become stale. Current timing observes deterministic
|
Health value that could become stale. Pre-event timing reads the combat snapshot
|
||||||
resource changes from earlier branches at the same event. Health-band mechanics
|
before mechanic branches process the event. Current timing observes
|
||||||
use strict upper bounds and inclusive lower bounds, so bands do not overlap and
|
deterministic resource changes from earlier branches at the same event.
|
||||||
an exact threshold enters the next weaker band. Maximum-potential evaluation
|
Health-band mechanics use strict upper bounds and inclusive lower bounds, so
|
||||||
applies the same predicates before emitting passive outcomes.
|
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,
|
Continuous restoration resolves the outcome's total when the branch activates,
|
||||||
then delivers that total at a constant rate over the configured duration.
|
then delivers that total at a constant rate over the configured duration.
|
||||||
@@ -793,17 +935,18 @@ applicable, default value, and best-case value. ISAC retains these definitions
|
|||||||
for continued mechanic authoring. Current Builder and shared Build pages do not
|
for continued mechanic authoring. Current Builder and shared Build pages do not
|
||||||
render or evaluate them. Their calculation panels display `COMING SOON` instead.
|
render or evaluate them. Their calculation panels display `COMING SOON` instead.
|
||||||
|
|
||||||
Loadout schema version 4 stores one focus, selected tree ranks, nine Skill
|
Loadout schema version 6 stores one focus, selected tree ranks, nine Skill
|
||||||
Chips, a unified conditional-effect state map, encounter assumptions,
|
Chips, independent T1/T2 choices for each equipped gear and weapon Talent, a
|
||||||
per-ability activation controls, and `calculationMode`. Runtime readers accept
|
unified conditional-effect state map, encounter assumptions, per-ability
|
||||||
the exact version 4 structure. The schema retains its simulation fields for
|
activation controls, and `calculationMode`. Runtime readers accept the exact
|
||||||
compatibility, but the current player UI ignores them. A backed-up prototype
|
version 6 structure. A backed-up migration translated version 5 equipment
|
||||||
migration removes drafts, saved builds, and calibration snapshots that use
|
Talents from their previous equipment-tier-derived behavior. The schema retains
|
||||||
incompatible payloads.
|
its simulation fields, but the current player UI ignores them. Version 4 remains
|
||||||
|
obsolete prototype data.
|
||||||
|
|
||||||
## Planned player calculation modes
|
## Planned player calculation modes
|
||||||
|
|
||||||
Loadout v4 defines `maximum` and `custom` modes for the planned simulation UI:
|
Loadout v6 defines `maximum` and `custom` modes for the planned simulation UI:
|
||||||
|
|
||||||
- Maximum mode supplies each scenario input's best-case value, selects peak
|
- Maximum mode supplies each scenario input's best-case value, selects peak
|
||||||
conditional-effect state, and runs selected abilities on cooldown against
|
conditional-effect state, and runs selected abilities on cooldown against
|
||||||
@@ -925,11 +1068,12 @@ does not scale.
|
|||||||
|
|
||||||
# Game-data icon storage
|
# Game-data icon storage
|
||||||
|
|
||||||
Talents, brand sets, OS Protocols, and Skill Chips reference uploaded artwork
|
Talents, brand sets, OS Protocols, Skill Chips, Specializations, and shared
|
||||||
through the polymorphic `game_data_icons` metadata table. The shared server
|
Weapon Mod slots reference uploaded artwork through the polymorphic
|
||||||
pipeline accepts transparent SVG, PNG, WebP, and AVIF sources, rejects active
|
`game_data_icons` metadata table. The shared server pipeline accepts transparent
|
||||||
SVG content and external references, and uses Sharp’s native worker pool to
|
SVG, PNG, WebP, and AVIF sources, rejects active SVG content and external
|
||||||
rasterize and normalize each accepted icon to a transparent 256 × 256 WebP.
|
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
|
Normalized files live beneath the ignored `data/game-icons/` directory. The
|
||||||
validated `/icons/game-data/[type]/[id]` endpoint serves them with immutable
|
validated `/icons/game-data/[type]/[id]` endpoint serves them with immutable
|
||||||
|
|||||||
Generated
+25
@@ -21,6 +21,7 @@
|
|||||||
"argon2": "^0.45.1",
|
"argon2": "^0.45.1",
|
||||||
"nodemailer": "^9.0.3",
|
"nodemailer": "^9.0.3",
|
||||||
"openai": "^6.49.0",
|
"openai": "^6.49.0",
|
||||||
|
"otpauth": "9.5.1",
|
||||||
"sharp": "^0.35.3"
|
"sharp": "^0.35.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -706,6 +707,18 @@
|
|||||||
"@emnapi/runtime": "^1.7.1"
|
"@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": {
|
"node_modules/@oxc-project/types": {
|
||||||
"version": "0.139.0",
|
"version": "0.139.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
|
||||||
@@ -3486,6 +3499,18 @@
|
|||||||
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
|
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/path-key": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
"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: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": "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",
|
"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": "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: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",
|
"db:backup:production": "infisical run --env=prod --path=/app -- node scripts/db-backup.mjs",
|
||||||
@@ -79,6 +80,7 @@
|
|||||||
"argon2": "^0.45.1",
|
"argon2": "^0.45.1",
|
||||||
"nodemailer": "^9.0.3",
|
"nodemailer": "^9.0.3",
|
||||||
"openai": "^6.49.0",
|
"openai": "^6.49.0",
|
||||||
|
"otpauth": "9.5.1",
|
||||||
"sharp": "^0.35.3"
|
"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);
|
isFiniteNumber(value.value);
|
||||||
const isRollPair = (value) =>
|
const isRollPair = (value) =>
|
||||||
Array.isArray(value) && value.length === 2 && value.every(isRoll);
|
Array.isArray(value) && value.length === 2 && value.every(isRoll);
|
||||||
const isGearSelection = (value) =>
|
const isTalentTier = (value) => value === 1 || value === 2;
|
||||||
|
const isLegacyGearSelection = (value) =>
|
||||||
hasExactKeys(value, [
|
hasExactKeys(value, [
|
||||||
'itemId',
|
'itemId',
|
||||||
'level',
|
'level',
|
||||||
@@ -68,7 +69,7 @@ const isGearSelection = (value) =>
|
|||||||
isRollPair(value.rolls) &&
|
isRollPair(value.rolls) &&
|
||||||
Array.isArray(value.augmentSlots) &&
|
Array.isArray(value.augmentSlots) &&
|
||||||
value.augmentSlots.every(isFiniteNumber);
|
value.augmentSlots.every(isFiniteNumber);
|
||||||
const isWeaponSelection = (value) =>
|
const isLegacyWeaponSelection = (value) =>
|
||||||
hasExactKeys(value, [
|
hasExactKeys(value, [
|
||||||
'itemId',
|
'itemId',
|
||||||
'level',
|
'level',
|
||||||
@@ -88,12 +89,68 @@ const isWeaponSelection = (value) =>
|
|||||||
(key) => isFiniteNumber(value[key]),
|
(key) => isFiniteNumber(value[key]),
|
||||||
) &&
|
) &&
|
||||||
isRollPair(value.rolls);
|
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) =>
|
const isChipSelection = (value) =>
|
||||||
hasExactKeys(value, ['setId', 'quality', 'primary', 'secondary']) &&
|
hasExactKeys(value, ['setId', 'quality', 'primary', 'secondary']) &&
|
||||||
typeof value.setId === 'string' &&
|
typeof value.setId === 'string' &&
|
||||||
(value.quality === 'Superior' || value.quality === 'High-end') &&
|
['Standard', 'Specialized', 'Superior', 'High-end'].includes(value.quality) &&
|
||||||
isRoll(value.primary) &&
|
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) =>
|
const isProtocolSelection = (value) =>
|
||||||
hasExactKeys(value, ['itemId', 'level', 'talentUpgrades']) &&
|
hasExactKeys(value, ['itemId', 'level', 'talentUpgrades']) &&
|
||||||
typeof value.itemId === 'string' &&
|
typeof value.itemId === 'string' &&
|
||||||
@@ -185,7 +242,7 @@ const isCurrentLoadoutSnapshot = (raw) => {
|
|||||||
'skillActivations',
|
'skillActivations',
|
||||||
'specialization',
|
'specialization',
|
||||||
]) &&
|
]) &&
|
||||||
value.version === 4 &&
|
value.version === 6 &&
|
||||||
typeof value.buildName === 'string' &&
|
typeof value.buildName === 'string' &&
|
||||||
['maximum', 'custom'].includes(value.calculationMode) &&
|
['maximum', 'custom'].includes(value.calculationMode) &&
|
||||||
hasExactKeys(value.gear, gearSlots) &&
|
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(
|
const databasePath = resolve(
|
||||||
process.env.TDR_DATABASE_PATH || './data/shd-build.db',
|
process.env.TDR_DATABASE_PATH || './data/shd-build.db',
|
||||||
);
|
);
|
||||||
@@ -669,6 +769,176 @@ for (const statement of statements) {
|
|||||||
await database.exec(statement);
|
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 (
|
const articleRevisionColumns = await (
|
||||||
await database.prepare("PRAGMA table_info('article_revisions')")
|
await database.prepare("PRAGMA table_info('article_revisions')")
|
||||||
).all();
|
).all();
|
||||||
@@ -2004,10 +2274,12 @@ if (
|
|||||||
user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||||
provider TEXT NOT NULL CHECK(provider IN ('password', 'discord', 'magic_link', 'passkey')),
|
provider TEXT NOT NULL CHECK(provider IN ('password', 'discord', 'magic_link', 'passkey')),
|
||||||
succeeded INTEGER NOT NULL CHECK(succeeded IN (0, 1)),
|
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
|
created_at TEXT NOT NULL
|
||||||
)`);
|
)`);
|
||||||
await database.exec(`INSERT INTO login_events(id,user_id,provider,succeeded,created_at)
|
await database.exec(`INSERT INTO login_events(id,user_id,provider,succeeded,aal,aal2_verified_at,created_at)
|
||||||
SELECT id,user_id,provider,succeeded,created_at FROM login_events_legacy`);
|
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('DROP TABLE login_events_legacy');
|
||||||
await database.exec(
|
await database.exec(
|
||||||
'CREATE INDEX IF NOT EXISTS login_events_user_created_idx ON login_events(user_id, created_at DESC)',
|
'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_ended_at', 'TEXT'],
|
||||||
['session_end_reason', 'TEXT'],
|
['session_end_reason', 'TEXT'],
|
||||||
['sensitive_purged_at', '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)) {
|
if (!loginEventColumns.some((column) => column.name === name)) {
|
||||||
await database.exec(
|
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 (
|
const proposalColumns = await (
|
||||||
await database.prepare("PRAGMA table_info('data_change_proposals')")
|
await database.prepare("PRAGMA table_info('data_change_proposals')")
|
||||||
).all();
|
).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 (
|
const managedImageColumns = await (
|
||||||
await database.prepare("PRAGMA table_info('managed_images')")
|
await database.prepare("PRAGMA table_info('managed_images')")
|
||||||
).all();
|
).all();
|
||||||
@@ -2433,6 +2747,7 @@ const iconLibraryCategory = {
|
|||||||
specialization: 'Specializations',
|
specialization: 'Specializations',
|
||||||
specialization_ability: 'Specializations',
|
specialization_ability: 'Specializations',
|
||||||
specialization_node: 'Specializations',
|
specialization_node: 'Specializations',
|
||||||
|
weapon_mod_slot: 'Weapon Mods',
|
||||||
};
|
};
|
||||||
const iconLibraryLabel = {
|
const iconLibraryLabel = {
|
||||||
talent: 'Talent',
|
talent: 'Talent',
|
||||||
@@ -2442,6 +2757,17 @@ const iconLibraryLabel = {
|
|||||||
specialization: 'Specialization',
|
specialization: 'Specialization',
|
||||||
specialization_ability: 'Specialization Ability',
|
specialization_ability: 'Specialization Ability',
|
||||||
specialization_node: 'Specialization Tree Node',
|
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) =>
|
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:
|
default:
|
||||||
return { name: null, tags: [] };
|
return { name: null, tags: [] };
|
||||||
}
|
}
|
||||||
@@ -2912,6 +3245,302 @@ if (
|
|||||||
await database.exec('PRAGMA foreign_keys=ON');
|
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 (
|
const buildColumns = await (
|
||||||
await database.prepare("PRAGMA table_info('builds')")
|
await database.prepare("PRAGMA table_info('builds')")
|
||||||
).all();
|
).all();
|
||||||
@@ -2926,9 +3555,34 @@ if (buildColumns.length) {
|
|||||||
await database.exec(
|
await database.exec(
|
||||||
"UPDATE builds SET visibility='private' WHERE visibility='draft'",
|
"UPDATE builds SET visibility='private' WHERE visibility='draft'",
|
||||||
);
|
);
|
||||||
const builds = await (
|
let builds = await (
|
||||||
await database.prepare('SELECT id,loadout_json FROM builds')
|
await database.prepare('SELECT id,loadout_json FROM builds')
|
||||||
).all();
|
).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
|
const obsoleteBuildIds = builds
|
||||||
.filter((build) => !isCurrentLoadoutSnapshot(build.loadout_json))
|
.filter((build) => !isCurrentLoadoutSnapshot(build.loadout_json))
|
||||||
.map((build) => String(build.id));
|
.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(
|
await database.prepare(
|
||||||
'SELECT id,loadout_snapshot_json FROM calculation_calibration_cases',
|
'SELECT id,loadout_snapshot_json FROM calculation_calibration_cases',
|
||||||
)
|
)
|
||||||
).all();
|
).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
|
const obsoleteCalibrationIds = calibrationCases
|
||||||
.filter(
|
.filter(
|
||||||
(calibrationCase) =>
|
(calibrationCase) =>
|
||||||
|
|||||||
+72
-2
@@ -44,21 +44,91 @@ for (const table of backup.tables) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let restoredRows = 0;
|
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');
|
await database.exec('BEGIN');
|
||||||
try {
|
try {
|
||||||
for (const table of backup.tables) {
|
for (const table of backup.tables) {
|
||||||
for (const row of table.rows) {
|
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;
|
if (!columns.length) continue;
|
||||||
const columnSql = columns.map(quoteIdentifier).join(', ');
|
const columnSql = columns.map(quoteIdentifier).join(', ');
|
||||||
const placeholders = columns.map(() => '?').join(', ');
|
const placeholders = columns.map(() => '?').join(', ');
|
||||||
const insert = await database.prepare(
|
const insert = await database.prepare(
|
||||||
`INSERT INTO ${quoteIdentifier(table.name)} (${columnSql}) VALUES (${placeholders})`,
|
`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++;
|
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');
|
await database.exec('COMMIT');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await database.exec('ROLLBACK');
|
await database.exec('ROLLBACK');
|
||||||
|
|||||||
@@ -98,8 +98,6 @@ try {
|
|||||||
weapon_min: record.weaponRange?.[0] ?? existing?.weapon_min ?? null,
|
weapon_min: record.weaponRange?.[0] ?? existing?.weapon_min ?? null,
|
||||||
weapon_max: record.weaponRange?.[1] ?? existing?.weapon_max ?? null,
|
weapon_max: record.weaponRange?.[1] ?? existing?.weapon_max ?? null,
|
||||||
weapon_reference_level: Number(existing?.weapon_reference_level ?? 50),
|
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,
|
cap_value: existing?.cap_value ?? null,
|
||||||
active: 1,
|
active: 1,
|
||||||
created_at: String(existing?.created_at ?? now),
|
created_at: String(existing?.created_at ?? now),
|
||||||
@@ -117,7 +115,7 @@ try {
|
|||||||
await (
|
await (
|
||||||
await database.prepare(`UPDATE stat_attributes SET
|
await database.prepare(`UPDATE stat_attributes SET
|
||||||
name=?,description=?,type=?,category=?,calculation_type=?,unit=?,
|
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=?`)
|
cap_value=?,active=1,updated_at=? WHERE id=?`)
|
||||||
).run(
|
).run(
|
||||||
next.name,
|
next.name,
|
||||||
@@ -130,8 +128,6 @@ try {
|
|||||||
next.gear_max,
|
next.gear_max,
|
||||||
next.weapon_min,
|
next.weapon_min,
|
||||||
next.weapon_max,
|
next.weapon_max,
|
||||||
next.skill_chip_min,
|
|
||||||
next.skill_chip_max,
|
|
||||||
next.cap_value,
|
next.cap_value,
|
||||||
now,
|
now,
|
||||||
id,
|
id,
|
||||||
@@ -142,8 +138,8 @@ try {
|
|||||||
await database.prepare(`INSERT INTO stat_attributes(
|
await database.prepare(`INSERT INTO stat_attributes(
|
||||||
id,name,description,type,category,calculation_type,unit,
|
id,name,description,type,category,calculation_type,unit,
|
||||||
gear_min,gear_max,gear_reference_level,weapon_min,weapon_max,weapon_reference_level,
|
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
|
cap_value,active,created_at,updated_at
|
||||||
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)`)
|
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)`)
|
||||||
).run(
|
).run(
|
||||||
id,
|
id,
|
||||||
next.name,
|
next.name,
|
||||||
@@ -158,8 +154,6 @@ try {
|
|||||||
next.weapon_min,
|
next.weapon_min,
|
||||||
next.weapon_max,
|
next.weapon_max,
|
||||||
next.weapon_reference_level,
|
next.weapon_reference_level,
|
||||||
next.skill_chip_min,
|
|
||||||
next.skill_chip_max,
|
|
||||||
next.cap_value,
|
next.cap_value,
|
||||||
now,
|
now,
|
||||||
now,
|
now,
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import { connect } from '@tursodatabase/database';
|
|||||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
import { basename, dirname, join, resolve } from 'node:path';
|
import { basename, dirname, join, resolve } from 'node:path';
|
||||||
import process from 'node:process';
|
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(
|
const databasePath = resolve(
|
||||||
process.env.TDR_DATABASE_PATH || './data/shd-build-development.db',
|
process.env.TDR_DATABASE_PATH || './data/shd-build-development.db',
|
||||||
@@ -67,7 +71,7 @@ for (const row of [
|
|||||||
|
|
||||||
const snapshot = {
|
const snapshot = {
|
||||||
format: 'shd-build-isac-data',
|
format: 'shd-build-isac-data',
|
||||||
version: 2,
|
version: ISAC_DATA_FORMAT_VERSION,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
sourceDatabase: basename(databasePath),
|
sourceDatabase: basename(databasePath),
|
||||||
totalRows,
|
totalRows,
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ import { spawnSync } from 'node:child_process';
|
|||||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
import { basename, join, resolve } from 'node:path';
|
import { basename, join, resolve } from 'node:path';
|
||||||
import process from 'node:process';
|
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
|
const snapshotArgument = process.argv
|
||||||
.slice(2)
|
.slice(2)
|
||||||
@@ -22,7 +26,7 @@ const snapshotPath = resolve(snapshotArgument);
|
|||||||
const snapshot = JSON.parse(await readFile(snapshotPath, 'utf8'));
|
const snapshot = JSON.parse(await readFile(snapshotPath, 'utf8'));
|
||||||
if (
|
if (
|
||||||
snapshot?.format !== 'shd-build-isac-data' ||
|
snapshot?.format !== 'shd-build-isac-data' ||
|
||||||
snapshot?.version !== 2 ||
|
snapshot?.version !== ISAC_DATA_FORMAT_VERSION ||
|
||||||
!Array.isArray(snapshot.tables) ||
|
!Array.isArray(snapshot.tables) ||
|
||||||
!Array.isArray(snapshot.icons)
|
!Array.isArray(snapshot.icons)
|
||||||
) {
|
) {
|
||||||
@@ -39,6 +43,233 @@ if (
|
|||||||
'The ISAC snapshot table manifest is incomplete or out of order.',
|
'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) {
|
for (const icon of snapshot.icons) {
|
||||||
if (
|
if (
|
||||||
typeof icon.storageKey !== 'string' ||
|
typeof icon.storageKey !== 'string' ||
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ export const ISAC_DATA_TABLES = [
|
|||||||
'skill_chip_sets',
|
'skill_chip_sets',
|
||||||
'skill_chip_two_piece_bonuses',
|
'skill_chip_two_piece_bonuses',
|
||||||
'skill_chip_primary_options',
|
'skill_chip_primary_options',
|
||||||
|
'skill_chip_primary_quality_values',
|
||||||
'skill_chip_secondary_options',
|
'skill_chip_secondary_options',
|
||||||
|
'skill_chip_secondary_quality_values',
|
||||||
'skill_chip_talents',
|
'skill_chip_talents',
|
||||||
'skill_chip_talent_effects',
|
'skill_chip_talent_effects',
|
||||||
'weapon_mods',
|
'weapon_mods',
|
||||||
@@ -55,6 +57,7 @@ export const ISAC_DATA_TABLES = [
|
|||||||
'specialization_combat_effects',
|
'specialization_combat_effects',
|
||||||
'specialization_focuses',
|
'specialization_focuses',
|
||||||
'specialization_abilities',
|
'specialization_abilities',
|
||||||
|
'specialization_ability_skill_chip_links',
|
||||||
'specialization_ability_attacks',
|
'specialization_ability_attacks',
|
||||||
'specialization_ability_effects',
|
'specialization_ability_effects',
|
||||||
'specialization_focus_abilities',
|
'specialization_focus_abilities',
|
||||||
@@ -73,5 +76,7 @@ export const ISAC_DATA_TABLES = [
|
|||||||
'managed_images',
|
'managed_images',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const ISAC_DATA_FORMAT_VERSION = 5;
|
||||||
|
|
||||||
export const quoteIdentifier = (identifier) =>
|
export const quoteIdentifier = (identifier) =>
|
||||||
`"${String(identifier).replaceAll('"', '""')}"`;
|
`"${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) {
|
for (const row of rows) {
|
||||||
if (
|
if (
|
||||||
typeof row.storage_key !== 'string' ||
|
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,
|
row.storage_key,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -14,6 +14,19 @@ if (!bundleArgument || !process.argv.includes('--confirm-merge')) {
|
|||||||
process.exit(2);
|
process.exit(2);
|
||||||
}
|
}
|
||||||
const updateExisting = process.argv.includes('--update-existing');
|
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 bundlePath = resolve(bundleArgument);
|
||||||
const bundle = JSON.parse(await readFile(bundlePath, 'utf8'));
|
const bundle = JSON.parse(await readFile(bundlePath, 'utf8'));
|
||||||
if (
|
if (
|
||||||
@@ -30,21 +43,21 @@ for (const image of bundle.images) {
|
|||||||
typeof image?.row?.id !== 'string' ||
|
typeof image?.row?.id !== 'string' ||
|
||||||
!/^[0-9a-f-]{36}$/i.test(image.row.id) ||
|
!/^[0-9a-f-]{36}$/i.test(image.row.id) ||
|
||||||
typeof image.row.storage_key !== 'string' ||
|
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,
|
image.row.storage_key,
|
||||||
) ||
|
) ||
|
||||||
typeof image.contentBase64 !== 'string'
|
typeof image.contentBase64 !== 'string'
|
||||||
) {
|
) {
|
||||||
throw new Error('The bundle contains an invalid managed image.');
|
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,
|
image.row.source_entity_type,
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
image.row.source_entity_type &&
|
image.row.source_entity_type &&
|
||||||
(typeof image.row.source_entity_id !== 'string' ||
|
(typeof image.row.source_entity_id !== 'string' ||
|
||||||
!/^[0-9a-f-]{36}$/i.test(image.row.source_entity_id) ||
|
!/^[0-9a-f-]{36}$/i.test(image.row.source_entity_id) ||
|
||||||
(isItemRender
|
(isGameDataRender
|
||||||
? !image.row.storage_key.startsWith('library-')
|
? !image.row.storage_key.startsWith('library-')
|
||||||
: !image.row.storage_key.startsWith(
|
: !image.row.storage_key.startsWith(
|
||||||
`${image.row.source_entity_type}-`,
|
`${image.row.source_entity_type}-`,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const APP_REQUIRED = [
|
|||||||
'APP_ORIGIN',
|
'APP_ORIGIN',
|
||||||
'ORIGIN',
|
'ORIGIN',
|
||||||
'PASSKEY_RP_ID',
|
'PASSKEY_RP_ID',
|
||||||
|
'MFA_TOTP_ENCRYPTION_KEYS',
|
||||||
'SCHEDULER_TOKEN',
|
'SCHEDULER_TOKEN',
|
||||||
'TDR_DATABASE_PATH',
|
'TDR_DATABASE_PATH',
|
||||||
'TDR_AVATAR_STORAGE_PATH',
|
'TDR_AVATAR_STORAGE_PATH',
|
||||||
@@ -48,6 +49,7 @@ export function validateRuntimeEnvironment(mode, environment) {
|
|||||||
|
|
||||||
requireValues(APP_REQUIRED);
|
requireValues(APP_REQUIRED);
|
||||||
validateSchedulerToken(get('SCHEDULER_TOKEN'), errors);
|
validateSchedulerToken(get('SCHEDULER_TOKEN'), errors);
|
||||||
|
validateMfaTotpEncryptionKeys(get('MFA_TOTP_ENCRYPTION_KEYS'), errors);
|
||||||
|
|
||||||
const appOrigin = validateOrigin('APP_ORIGIN', get('APP_ORIGIN'), errors);
|
const appOrigin = validateOrigin('APP_ORIGIN', get('APP_ORIGIN'), errors);
|
||||||
const adapterOrigin = validateOrigin('ORIGIN', get('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',
|
APP_ORIGIN: 'https://stage.shd.build',
|
||||||
ORIGIN: 'https://stage.shd.build',
|
ORIGIN: 'https://stage.shd.build',
|
||||||
PASSKEY_RP_ID: 'shd.build',
|
PASSKEY_RP_ID: 'shd.build',
|
||||||
|
MFA_TOTP_ENCRYPTION_KEYS:
|
||||||
|
'primary-2026:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
||||||
SCHEDULER_TOKEN: 'scheduler-secret-with-32-characters',
|
SCHEDULER_TOKEN: 'scheduler-secret-with-32-characters',
|
||||||
TDR_DATABASE_PATH: '/app/data/shd-build-staging.db',
|
TDR_DATABASE_PATH: '/app/data/shd-build-staging.db',
|
||||||
TDR_AVATAR_STORAGE_PATH: '/app/data/avatars',
|
TDR_AVATAR_STORAGE_PATH: '/app/data/avatars',
|
||||||
@@ -21,6 +23,7 @@ describe('validateRuntimeEnvironment', () => {
|
|||||||
'APP_ORIGIN is required.',
|
'APP_ORIGIN is required.',
|
||||||
'ORIGIN is required.',
|
'ORIGIN is required.',
|
||||||
'PASSKEY_RP_ID is required.',
|
'PASSKEY_RP_ID is required.',
|
||||||
|
'MFA_TOTP_ENCRYPTION_KEYS is required.',
|
||||||
'SCHEDULER_TOKEN is required.',
|
'SCHEDULER_TOKEN is required.',
|
||||||
'TDR_DATABASE_PATH is required.',
|
'TDR_DATABASE_PATH is required.',
|
||||||
'TDR_AVATAR_STORAGE_PATH is required.',
|
'TDR_AVATAR_STORAGE_PATH is required.',
|
||||||
@@ -158,4 +161,24 @@ describe('validateRuntimeEnvironment', () => {
|
|||||||
validateRuntimeEnvironment('worker' as 'app', validAppEnvironment),
|
validateRuntimeEnvironment('worker' as 'app', validAppEnvironment),
|
||||||
).toThrow('Unsupported runtime mode: worker');
|
).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([
|
const APP_SECRET_NAMES = new Set([
|
||||||
'APP_ORIGIN',
|
'APP_ORIGIN',
|
||||||
|
'MFA_TOTP_ENCRYPTION_KEYS',
|
||||||
'DISCORD_CLIENT_ID',
|
'DISCORD_CLIENT_ID',
|
||||||
'DISCORD_CLIENT_SECRET',
|
'DISCORD_CLIENT_SECRET',
|
||||||
'DISCORD_REDIRECT_URI',
|
'DISCORD_REDIRECT_URI',
|
||||||
|
|||||||
+814
-22
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 { stringify } from 'devalue';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { getSessionUser } from '$lib/server/auth/session';
|
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 {
|
import {
|
||||||
getSiteAccessBlock,
|
getSiteAccessBlock,
|
||||||
getSiteAccessSettings,
|
getSiteAccessSettings,
|
||||||
@@ -49,6 +58,62 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
{ status: 404 },
|
{ 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') {
|
if (event.request.method === 'POST') {
|
||||||
const match = event.url.pathname.match(/^\/isac\/([^/]+)$/);
|
const match = event.url.pathname.match(/^\/isac\/([^/]+)$/);
|
||||||
const module = match?.[1] as DataModule | undefined;
|
const module = match?.[1] as DataModule | undefined;
|
||||||
|
|||||||
@@ -91,6 +91,172 @@ describe('article documents', () => {
|
|||||||
).toBeNull();
|
).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', () => {
|
it('rejects duplicate embed keys and unknown envelope fields', () => {
|
||||||
const embed = {
|
const embed = {
|
||||||
type: 'buildEmbed',
|
type: 'buildEmbed',
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const EMBED_KEY_PATTERN = /^[a-z0-9][a-z0-9-]{7,63}$/;
|
|||||||
|
|
||||||
export type ArticleMark = {
|
export type ArticleMark = {
|
||||||
type: 'bold' | 'italic' | 'code' | 'link';
|
type: 'bold' | 'italic' | 'code' | 'link';
|
||||||
attrs?: { href: string };
|
attrs?: { href: string; title?: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ArticleNode = {
|
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 (value === undefined) return [];
|
||||||
if (!Array.isArray(value) || value.length > 4) return null;
|
if (!Array.isArray(value) || value.length > 4) return null;
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
@@ -77,10 +87,31 @@ function parseMarks(value: unknown): ArticleMark[] | null {
|
|||||||
!isRecord(candidate.attrs)
|
!isRecord(candidate.attrs)
|
||||||
)
|
)
|
||||||
return null;
|
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);
|
const href = safeLink(candidate.attrs.href);
|
||||||
if (!href) return null;
|
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;
|
return marks;
|
||||||
}
|
}
|
||||||
@@ -134,7 +165,9 @@ export function parseArticleDocument(value: unknown): {
|
|||||||
if (/\p{Cc}/u.test(text.replace(/[\n\t]/g, ''))) return null;
|
if (/\p{Cc}/u.test(text.replace(/[\n\t]/g, ''))) return null;
|
||||||
textLength += [...text].length;
|
textLength += [...text].length;
|
||||||
if (textLength > MAX_ARTICLE_TEXT_CHARACTERS) return null;
|
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;
|
if (!marks) return null;
|
||||||
textParts.push(text);
|
textParts.push(text);
|
||||||
return marks.length ? { type, text, marks } : { type, text };
|
return marks.length ? { type, text, marks } : { type, text };
|
||||||
@@ -211,6 +244,31 @@ export function parseArticleDocument(value: unknown): {
|
|||||||
)
|
)
|
||||||
return null;
|
return null;
|
||||||
attrs = { level: Number(candidate.attrs.level) };
|
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) {
|
} else if (candidate.attrs !== undefined) {
|
||||||
if (!isRecord(candidate.attrs) || Object.keys(candidate.attrs).length)
|
if (!isRecord(candidate.attrs) || Object.keys(candidate.attrs).length)
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ describe('loadout mechanic timeline', () => {
|
|||||||
).toEqual([1, 2]);
|
).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 = {
|
const persistedRule = {
|
||||||
rule: {
|
rule: {
|
||||||
id: 'tier-i-only',
|
id: 'tier-i-only',
|
||||||
@@ -327,6 +327,7 @@ describe('loadout mechanic timeline', () => {
|
|||||||
const adapted = equippedConditionalProgram({
|
const adapted = equippedConditionalProgram({
|
||||||
key: 'weapon:0:exotic-talent',
|
key: 'weapon:0:exotic-talent',
|
||||||
tier: 2,
|
tier: 2,
|
||||||
|
isExotic: true,
|
||||||
rule: persistedRule,
|
rule: persistedRule,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -283,7 +283,7 @@ export function equippedConditionalProgram(
|
|||||||
: 'base';
|
: 'base';
|
||||||
return (
|
return (
|
||||||
conditionalProgram(equipped.rule, variant) ??
|
conditionalProgram(equipped.rule, variant) ??
|
||||||
(equipmentTalent && variant === 'II'
|
(equipmentTalent && equipped.isExotic && variant === 'II'
|
||||||
? conditionalProgram(equipped.rule, 'I')
|
? conditionalProgram(equipped.rule, 'I')
|
||||||
: null)
|
: null)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ describe('summarizeLoadout', () => {
|
|||||||
it('returns an empty deterministic summary for a partial draft', () => {
|
it('returns an empty deterministic summary for a partial draft', () => {
|
||||||
expect(summarizeLoadout(createDraft(), catalog())).toEqual({
|
expect(summarizeLoadout(createDraft(), catalog())).toEqual({
|
||||||
stats: [],
|
stats: [],
|
||||||
|
abilityStats: [],
|
||||||
activeBonuses: [],
|
activeBonuses: [],
|
||||||
equippedTalents: [],
|
equippedTalents: [],
|
||||||
});
|
});
|
||||||
@@ -206,7 +207,7 @@ describe('summarizeLoadout', () => {
|
|||||||
});
|
});
|
||||||
expect(summary.equippedTalents).toEqual([
|
expect(summary.equippedTalents).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
key: 'chips:chip-set',
|
key: 'chips:chip-set:ability',
|
||||||
tier: 1,
|
tier: 1,
|
||||||
abilityId: 'ability',
|
abilityId: 'ability',
|
||||||
description: 'Gain 25% Skill Damage.',
|
description: 'Gain 25% Skill Damage.',
|
||||||
@@ -214,6 +215,166 @@ describe('summarizeLoadout', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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', () => {
|
it('scales Protocol primary stats and Talent descriptions independently', () => {
|
||||||
const draft = createDraft();
|
const draft = createDraft();
|
||||||
draft.protocol = { itemId: 'protocol', level: 50, talentUpgrades: 5 };
|
draft.protocol = { itemId: 'protocol', level: 50, talentUpgrades: 5 };
|
||||||
@@ -322,11 +483,58 @@ describe('summarizeLoadout', () => {
|
|||||||
expect(stat(summary, 'mechanic')).toBeUndefined();
|
expect(stat(summary, 'mechanic')).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the selected Talent tier without applying its mechanic outcomes to stats', () => {
|
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();
|
const draft = createDraft();
|
||||||
draft.weapons[0].itemId = 'weapon';
|
draft.weapons[0].itemId = 'weapon';
|
||||||
draft.weapons[0].tier = 2;
|
draft.weapons[0].tier = 2;
|
||||||
draft.weapons[0].talent1Id = 'talent';
|
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 = {
|
const rule = {
|
||||||
clauses: [
|
clauses: [
|
||||||
@@ -352,13 +560,13 @@ describe('summarizeLoadout', () => {
|
|||||||
{
|
{
|
||||||
id: 'weapon',
|
id: 'weapon',
|
||||||
name: 'Test Weapon',
|
name: 'Test Weapon',
|
||||||
exotic_talent_id: null,
|
exotic_talent_id: 'exotic-talent',
|
||||||
baseBonuses: [],
|
baseBonuses: [],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
talents: [
|
talents: [
|
||||||
{
|
{
|
||||||
id: 'talent',
|
id: 'talent-one',
|
||||||
name: 'Measured',
|
name: 'Measured',
|
||||||
template: 'Gain {1} for {2}.',
|
template: 'Gain {1} for {2}.',
|
||||||
tiers: [
|
tiers: [
|
||||||
@@ -379,17 +587,60 @@ describe('summarizeLoadout', () => {
|
|||||||
],
|
],
|
||||||
rule,
|
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(summary.equippedTalents).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: 'talent',
|
id: 'talent-one',
|
||||||
tier: 2,
|
tier: 1,
|
||||||
description: 'Gain 20% for 3 Shots.',
|
tierLabel: 'TIER I',
|
||||||
|
description: 'Gain 10% for 2 Shots.',
|
||||||
rule,
|
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();
|
expect(stat(summary, 'talent-stat')).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { gearSlots, scaled, type LoadoutDraft } from '$lib/builder/loadout';
|
import {
|
||||||
|
gearSlots,
|
||||||
|
scaled,
|
||||||
|
type LoadoutDraft,
|
||||||
|
type TalentTier,
|
||||||
|
} from '$lib/builder/loadout';
|
||||||
|
|
||||||
export type LoadoutCatalog = {
|
export type LoadoutCatalog = {
|
||||||
attributes: any[];
|
attributes: any[];
|
||||||
@@ -7,6 +12,10 @@ export type LoadoutCatalog = {
|
|||||||
brands: any[];
|
brands: any[];
|
||||||
weapons: any[];
|
weapons: any[];
|
||||||
talents: any[];
|
talents: any[];
|
||||||
|
skillChipProfile?: {
|
||||||
|
primaryOptions: any[];
|
||||||
|
secondaryOptions: any[];
|
||||||
|
};
|
||||||
chipSets: any[];
|
chipSets: any[];
|
||||||
protocols: any[];
|
protocols: any[];
|
||||||
specializations: any[];
|
specializations: any[];
|
||||||
@@ -14,6 +23,20 @@ export type LoadoutCatalog = {
|
|||||||
|
|
||||||
export type LoadoutSummary = {
|
export type LoadoutSummary = {
|
||||||
stats: Array<{ id: string; value: number; sourceCount: number }>;
|
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[];
|
activeBonuses: any[];
|
||||||
equippedTalents: any[];
|
equippedTalents: any[];
|
||||||
};
|
};
|
||||||
@@ -82,18 +105,21 @@ function unitValue(catalog: LoadoutCatalog, value: unknown, statId: string) {
|
|||||||
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`;
|
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function talentTier(catalog: LoadoutCatalog, talentId: string, tier: number) {
|
function talentTier(
|
||||||
|
catalog: LoadoutCatalog,
|
||||||
|
talentId: string,
|
||||||
|
tier: TalentTier,
|
||||||
|
) {
|
||||||
const item = catalog.talents.find((entry) => entry.id === talentId);
|
const item = catalog.talents.find((entry) => entry.id === talentId);
|
||||||
return (
|
return item?.tiers.find(
|
||||||
item?.tiers.find((entry: any) => entry.tier === (tier >= 2 ? 'II' : 'I')) ??
|
(entry: any) => entry.tier === (tier === 2 ? 'II' : 'I'),
|
||||||
item?.tiers[0]
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function talentDescription(
|
function talentDescription(
|
||||||
catalog: LoadoutCatalog,
|
catalog: LoadoutCatalog,
|
||||||
talentId: string,
|
talentId: string,
|
||||||
tier: number,
|
tier: TalentTier,
|
||||||
) {
|
) {
|
||||||
const item = catalog.talents.find((entry) => entry.id === talentId);
|
const item = catalog.talents.find((entry) => entry.id === talentId);
|
||||||
const configured = talentTier(catalog, talentId, tier);
|
const configured = talentTier(catalog, talentId, tier);
|
||||||
@@ -139,6 +165,55 @@ export function summarizeLoadout(
|
|||||||
const brandCounts = new Map<string, number>();
|
const brandCounts = new Map<string, number>();
|
||||||
const activeBonuses: any[] = [];
|
const activeBonuses: any[] = [];
|
||||||
const equippedTalents: 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) {
|
for (const slot of gearSlots) {
|
||||||
const selection = draft.gear[slot];
|
const selection = draft.gear[slot];
|
||||||
@@ -188,12 +263,14 @@ export function summarizeLoadout(
|
|||||||
rule:
|
rule:
|
||||||
catalog.talents.find((entry) => entry.id === selection.talentId)
|
catalog.talents.find((entry) => entry.id === selection.talentId)
|
||||||
?.rule ?? null,
|
?.rule ?? null,
|
||||||
tier: Number(item.tier),
|
tier: selection.talentTier,
|
||||||
|
tierLabel: selection.talentTier === 2 ? 'TIER II' : 'TIER I',
|
||||||
|
isExotic: false,
|
||||||
source: slot,
|
source: slot,
|
||||||
description: talentDescription(
|
description: talentDescription(
|
||||||
catalog,
|
catalog,
|
||||||
selection.talentId,
|
selection.talentId,
|
||||||
Number(item.tier),
|
selection.talentTier,
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -238,42 +315,98 @@ export function summarizeLoadout(
|
|||||||
roll.value,
|
roll.value,
|
||||||
`${index ? 'Secondary' : 'Primary'} weapon roll`,
|
`${index ? 'Secondary' : 'Primary'} weapon roll`,
|
||||||
);
|
);
|
||||||
for (const talentId of [
|
const equippedWeaponTalents = [
|
||||||
selection.talent1Id,
|
{
|
||||||
selection.talent2Id,
|
id: selection.talent1Id,
|
||||||
item.exotic_talent_id,
|
tier: selection.talent1Tier,
|
||||||
].filter(Boolean)) {
|
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({
|
equippedTalents.push({
|
||||||
key: `weapon:${index}:${talentId}`,
|
key: `weapon:${index}:${equippedTalent.id}`,
|
||||||
id: talentId,
|
id: equippedTalent.id,
|
||||||
rule:
|
rule:
|
||||||
catalog.talents.find((entry) => entry.id === talentId)?.rule ?? null,
|
catalog.talents.find((entry) => entry.id === equippedTalent.id)
|
||||||
tier: selection.tier,
|
?.rule ?? null,
|
||||||
|
tier: equippedTalent.tier,
|
||||||
|
tierLabel: equippedTalent.tier === 2 ? 'TIER II' : 'TIER I',
|
||||||
|
isExotic: equippedTalent.isExotic,
|
||||||
source: index ? 'Secondary weapon' : 'Primary weapon',
|
source: index ? 'Secondary weapon' : 'Primary weapon',
|
||||||
description: talentDescription(catalog, talentId, selection.tier),
|
description: talentDescription(
|
||||||
|
catalog,
|
||||||
|
equippedTalent.id,
|
||||||
|
equippedTalent.tier,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const chipGroups = new Map<
|
const chipGroups = new Map<
|
||||||
string,
|
string,
|
||||||
{ count: number; highEnd: number; item: any }
|
{ 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) {
|
for (let index = 0; index < 9; index += 1) {
|
||||||
const selection = draft.chips[index];
|
const selection = draft.chips[index];
|
||||||
const item = catalog.chipSets.find((entry) => entry.id === selection.setId);
|
const item = catalog.chipSets.find((entry) => entry.id === selection.setId);
|
||||||
if (!item) continue;
|
if (!item) continue;
|
||||||
add(
|
const slotAbilityId = String(
|
||||||
selection.primary.statId,
|
focus?.abilities?.[Math.floor(index / 3)]?.id ?? '',
|
||||||
selection.primary.value,
|
|
||||||
`Skill Chip ${index + 1} primary`,
|
|
||||||
);
|
);
|
||||||
|
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)
|
for (const roll of selection.secondary)
|
||||||
add(roll.statId, roll.value, `Skill Chip ${index + 1} secondary`);
|
if (validChipRoll('secondaryOptions', selection.quality, roll))
|
||||||
const group = chipGroups.get(item.id) ?? { count: 0, highEnd: 0, item };
|
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;
|
group.count += 1;
|
||||||
if (selection.quality === 'High-end') group.highEnd += 1;
|
if (selection.quality === 'High-end') group.highEnd += 1;
|
||||||
chipGroups.set(item.id, group);
|
chipGroups.set(groupKey, group);
|
||||||
}
|
}
|
||||||
for (const group of chipGroups.values()) {
|
for (const group of chipGroups.values()) {
|
||||||
if (group.count >= 2 && group.item.twoPiece) {
|
if (group.count >= 2 && group.item.twoPiece) {
|
||||||
@@ -298,12 +431,12 @@ export function summarizeLoadout(
|
|||||||
}
|
}
|
||||||
if (group.count === 3 && group.item.talent) {
|
if (group.count === 3 && group.item.talent) {
|
||||||
equippedTalents.push({
|
equippedTalents.push({
|
||||||
key: `chips:${group.item.id}`,
|
key: `chips:${group.item.id}:${group.abilityId}`,
|
||||||
id: '',
|
id: '',
|
||||||
custom: group.item.talent,
|
custom: group.item.talent,
|
||||||
rule: group.item.talent.rule ?? null,
|
rule: group.item.talent.rule ?? null,
|
||||||
tier: group.highEnd,
|
tier: group.highEnd,
|
||||||
abilityId: group.item.specialization_ability_id ?? null,
|
abilityId: group.abilityId || null,
|
||||||
source: '3 Skill Chips',
|
source: '3 Skill Chips',
|
||||||
description: group.item.talent.description_template.replace(
|
description: group.item.talent.description_template.replace(
|
||||||
/\{(\d+)\}/g,
|
/\{(\d+)\}/g,
|
||||||
@@ -390,6 +523,28 @@ export function summarizeLoadout(
|
|||||||
catalogAttribute(catalog, right.id)?.name ?? '',
|
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,
|
activeBonuses,
|
||||||
equippedTalents,
|
equippedTalents,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,20 +1,91 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
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 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', () => {
|
describe('parseLoadoutDraft', () => {
|
||||||
it('accepts a current draft and returns an independent clone', () => {
|
it('accepts a current draft and returns an independent clone', () => {
|
||||||
const draft = createDraft();
|
const draft = createDraft();
|
||||||
const parsed = parseLoadoutDraft(draft);
|
const parsed = parseLoadoutDraft(draft);
|
||||||
|
|
||||||
expect(parsed).toEqual(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?.calculationMode).toBe('maximum');
|
||||||
expect(parsed).not.toBe(draft);
|
expect(parsed).not.toBe(draft);
|
||||||
parsed!.gear.Holster.level = 1;
|
parsed!.gear.Holster.level = 1;
|
||||||
expect(draft.gear.Holster.level).toBe(50);
|
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', () => {
|
it('rejects the retired version 1 three-chip shape', () => {
|
||||||
const retired = copyDraft() as unknown as Record<string, unknown>;
|
const retired = copyDraft() as unknown as Record<string, unknown>;
|
||||||
retired.version = 1;
|
retired.version = 1;
|
||||||
@@ -23,13 +94,125 @@ describe('parseLoadoutDraft', () => {
|
|||||||
expect(parseLoadoutDraft(retired)).toBeNull();
|
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();
|
const draft = copyDraft();
|
||||||
draft.chips.pop();
|
draft.chips.pop();
|
||||||
|
|
||||||
expect(parseLoadoutDraft(draft)).toBeNull();
|
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', () => {
|
it('rejects a draft missing the unified combat profile', () => {
|
||||||
const draft = copyDraft() as unknown as Record<string, unknown>;
|
const draft = copyDraft() as unknown as Record<string, unknown>;
|
||||||
delete draft.combatProfile;
|
delete draft.combatProfile;
|
||||||
|
|||||||
@@ -1,9 +1,24 @@
|
|||||||
export type Roll = { statId: string; value: number };
|
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 = {
|
export type GearSelection = {
|
||||||
itemId: string;
|
itemId: string;
|
||||||
level: number;
|
level: number;
|
||||||
brandId: string;
|
brandId: string;
|
||||||
talentId: string;
|
talentId: string;
|
||||||
|
talentTier: TalentTier;
|
||||||
health: number;
|
health: number;
|
||||||
armor: number;
|
armor: number;
|
||||||
firepower: number;
|
firepower: number;
|
||||||
@@ -21,14 +36,17 @@ export type WeaponSelection = {
|
|||||||
toughness: number;
|
toughness: number;
|
||||||
engineering: number;
|
engineering: number;
|
||||||
talent1Id: string;
|
talent1Id: string;
|
||||||
|
talent1Tier: TalentTier;
|
||||||
talent2Id: string;
|
talent2Id: string;
|
||||||
|
talent2Tier: TalentTier;
|
||||||
|
exoticTalentTier: TalentTier;
|
||||||
rolls: [Roll, Roll];
|
rolls: [Roll, Roll];
|
||||||
};
|
};
|
||||||
export type ChipSelection = {
|
export type ChipSelection = {
|
||||||
setId: string;
|
setId: string;
|
||||||
quality: 'Superior' | 'High-end';
|
quality: SkillChipQuality;
|
||||||
primary: Roll;
|
primary: Roll;
|
||||||
secondary: [Roll, Roll];
|
secondary: Roll[];
|
||||||
};
|
};
|
||||||
export type ProtocolSelection = {
|
export type ProtocolSelection = {
|
||||||
itemId: string;
|
itemId: string;
|
||||||
@@ -68,7 +86,7 @@ export type SpecializationSelection = {
|
|||||||
nodeRanks: Record<string, number>;
|
nodeRanks: Record<string, number>;
|
||||||
};
|
};
|
||||||
export type LoadoutDraft = {
|
export type LoadoutDraft = {
|
||||||
version: 4;
|
version: 6;
|
||||||
buildName: string;
|
buildName: string;
|
||||||
calculationMode: CalculationMode;
|
calculationMode: CalculationMode;
|
||||||
gear: Record<string, GearSelection>;
|
gear: Record<string, GearSelection>;
|
||||||
@@ -105,6 +123,7 @@ export const blankGear = (): GearSelection => ({
|
|||||||
level: 50,
|
level: 50,
|
||||||
brandId: '',
|
brandId: '',
|
||||||
talentId: '',
|
talentId: '',
|
||||||
|
talentTier: 1,
|
||||||
health: 0,
|
health: 0,
|
||||||
armor: 0,
|
armor: 0,
|
||||||
firepower: 0,
|
firepower: 0,
|
||||||
@@ -122,14 +141,17 @@ export const blankWeapon = (): WeaponSelection => ({
|
|||||||
toughness: 0,
|
toughness: 0,
|
||||||
engineering: 0,
|
engineering: 0,
|
||||||
talent1Id: '',
|
talent1Id: '',
|
||||||
|
talent1Tier: 1,
|
||||||
talent2Id: '',
|
talent2Id: '',
|
||||||
|
talent2Tier: 1,
|
||||||
|
exoticTalentTier: 1,
|
||||||
rolls: [blankRoll(), blankRoll()],
|
rolls: [blankRoll(), blankRoll()],
|
||||||
});
|
});
|
||||||
export const blankChip = (): ChipSelection => ({
|
export const blankChip = (): ChipSelection => ({
|
||||||
setId: '',
|
setId: '',
|
||||||
quality: 'Superior',
|
quality: 'Standard',
|
||||||
primary: blankRoll(),
|
primary: blankRoll(),
|
||||||
secondary: [blankRoll(), blankRoll()],
|
secondary: [],
|
||||||
});
|
});
|
||||||
export const defaultCombatProfile = (): CombatProfile => ({
|
export const defaultCombatProfile = (): CombatProfile => ({
|
||||||
encounterSeconds: 60,
|
encounterSeconds: 60,
|
||||||
@@ -148,7 +170,7 @@ export const defaultCombatProfile = (): CombatProfile => ({
|
|||||||
extraHealthActive: false,
|
extraHealthActive: false,
|
||||||
});
|
});
|
||||||
export const createDraft = (): LoadoutDraft => ({
|
export const createDraft = (): LoadoutDraft => ({
|
||||||
version: 4,
|
version: 6,
|
||||||
buildName: 'Untitled loadout',
|
buildName: 'Untitled loadout',
|
||||||
calculationMode: 'maximum',
|
calculationMode: 'maximum',
|
||||||
gear: Object.fromEntries(gearSlots.map((slot) => [slot, blankGear()])),
|
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> =>
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||||
const hasExactKeys = (value: Record<string, unknown>, keys: string[]) =>
|
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);
|
keys.every((key) => key in value);
|
||||||
const isFiniteNumber = (value: unknown): value is number =>
|
const isFiniteNumber = (value: unknown): value is number =>
|
||||||
typeof value === 'number' && Number.isFinite(value);
|
typeof value === 'number' && Number.isFinite(value);
|
||||||
|
const isTalentTier = (value: unknown): value is TalentTier =>
|
||||||
|
value === 1 || value === 2;
|
||||||
const isNumberRecord = (value: unknown) =>
|
const isNumberRecord = (value: unknown) =>
|
||||||
isRecord(value) &&
|
isRecord(value) &&
|
||||||
Object.values(value).every(
|
Object.values(value).every(
|
||||||
@@ -200,6 +257,7 @@ const isGearSelection = (value: unknown): value is GearSelection =>
|
|||||||
'level',
|
'level',
|
||||||
'brandId',
|
'brandId',
|
||||||
'talentId',
|
'talentId',
|
||||||
|
'talentTier',
|
||||||
'health',
|
'health',
|
||||||
'armor',
|
'armor',
|
||||||
'firepower',
|
'firepower',
|
||||||
@@ -211,6 +269,7 @@ const isGearSelection = (value: unknown): value is GearSelection =>
|
|||||||
['itemId', 'brandId', 'talentId'].every(
|
['itemId', 'brandId', 'talentId'].every(
|
||||||
(key) => typeof value[key] === 'string',
|
(key) => typeof value[key] === 'string',
|
||||||
) &&
|
) &&
|
||||||
|
isTalentTier(value.talentTier) &&
|
||||||
['level', 'health', 'armor', 'firepower', 'toughness', 'engineering'].every(
|
['level', 'health', 'armor', 'firepower', 'toughness', 'engineering'].every(
|
||||||
(key) => isFiniteNumber(value[key]),
|
(key) => isFiniteNumber(value[key]),
|
||||||
) &&
|
) &&
|
||||||
@@ -228,12 +287,18 @@ const isWeaponSelection = (value: unknown): value is WeaponSelection =>
|
|||||||
'toughness',
|
'toughness',
|
||||||
'engineering',
|
'engineering',
|
||||||
'talent1Id',
|
'talent1Id',
|
||||||
|
'talent1Tier',
|
||||||
'talent2Id',
|
'talent2Id',
|
||||||
|
'talent2Tier',
|
||||||
|
'exoticTalentTier',
|
||||||
'rolls',
|
'rolls',
|
||||||
]) &&
|
]) &&
|
||||||
['itemId', 'talent1Id', 'talent2Id'].every(
|
['itemId', 'talent1Id', 'talent2Id'].every(
|
||||||
(key) => typeof value[key] === 'string',
|
(key) => typeof value[key] === 'string',
|
||||||
) &&
|
) &&
|
||||||
|
isTalentTier(value.talent1Tier) &&
|
||||||
|
isTalentTier(value.talent2Tier) &&
|
||||||
|
isTalentTier(value.exoticTalentTier) &&
|
||||||
['level', 'tier', 'damage', 'firepower', 'toughness', 'engineering'].every(
|
['level', 'tier', 'damage', 'firepower', 'toughness', 'engineering'].every(
|
||||||
(key) => isFiniteNumber(value[key]),
|
(key) => isFiniteNumber(value[key]),
|
||||||
) &&
|
) &&
|
||||||
@@ -242,9 +307,12 @@ const isChipSelection = (value: unknown): value is ChipSelection =>
|
|||||||
isRecord(value) &&
|
isRecord(value) &&
|
||||||
hasExactKeys(value, ['setId', 'quality', 'primary', 'secondary']) &&
|
hasExactKeys(value, ['setId', 'quality', 'primary', 'secondary']) &&
|
||||||
typeof value.setId === 'string' &&
|
typeof value.setId === 'string' &&
|
||||||
(value.quality === 'Superior' || value.quality === 'High-end') &&
|
skillChipQualities.some((quality) => value.quality === quality) &&
|
||||||
isRoll(value.primary) &&
|
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 =>
|
const isProtocolSelection = (value: unknown): value is ProtocolSelection =>
|
||||||
isRecord(value) &&
|
isRecord(value) &&
|
||||||
hasExactKeys(value, ['itemId', 'level', 'talentUpgrades']) &&
|
hasExactKeys(value, ['itemId', 'level', 'talentUpgrades']) &&
|
||||||
@@ -353,7 +421,7 @@ export function parseLoadoutDraft(value: unknown): LoadoutDraft | null {
|
|||||||
'skillActivations',
|
'skillActivations',
|
||||||
'specialization',
|
'specialization',
|
||||||
]) ||
|
]) ||
|
||||||
value.version !== 4 ||
|
value.version !== 6 ||
|
||||||
typeof value.buildName !== 'string' ||
|
typeof value.buildName !== 'string' ||
|
||||||
!['maximum', 'custom'].includes(String(value.calculationMode))
|
!['maximum', 'custom'].includes(String(value.calculationMode))
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
currentHealthPercentBandPredicate,
|
currentHealthPercentBandPredicate,
|
||||||
eventFieldComparisonPredicate,
|
eventFieldComparisonPredicate,
|
||||||
|
mechanicTargetingForMode,
|
||||||
|
mechanicTargetingMode,
|
||||||
parseConditionalMechanicDraft,
|
parseConditionalMechanicDraft,
|
||||||
readCurrentHealthPercentBand,
|
readCurrentHealthPercentBand,
|
||||||
readEventFieldComparison,
|
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', () => {
|
it('rejects an unregistered Extra Health event field deterministically', () => {
|
||||||
const value = draft();
|
const value = draft();
|
||||||
value.clauses[0].event = 'extra_health_changed';
|
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', () => {
|
it('preserves pre-event timing for current Health percentage references', () => {
|
||||||
const invalid = draft();
|
const value = draft();
|
||||||
invalid.clauses[0].if = {
|
value.clauses[0].if = {
|
||||||
kind: 'compare',
|
kind: 'compare',
|
||||||
left: {
|
left: {
|
||||||
source: 'combat',
|
source: 'combat',
|
||||||
@@ -697,11 +803,13 @@ describe('conditional mechanic authoring payload', () => {
|
|||||||
operator: 'lt',
|
operator: 'lt',
|
||||||
right: 50,
|
right: 50,
|
||||||
};
|
};
|
||||||
const result = parseConditionalMechanicDraft(invalid, {
|
const result = parseConditionalMechanicDraft(value, {
|
||||||
variantKeys: ['I', 'II'],
|
variantKeys: ['I', 'II'],
|
||||||
allowedAttributeIds: new Set(['weapon-damage']),
|
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', () => {
|
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 {
|
import {
|
||||||
MECHANIC_EVENT_KINDS,
|
MECHANIC_EVENT_KINDS,
|
||||||
MECHANIC_EVENT_REGISTRY,
|
MECHANIC_EVENT_REGISTRY,
|
||||||
|
mechanicOutcomeTargetingError,
|
||||||
validateMechanicProgram,
|
validateMechanicProgram,
|
||||||
type ClauseMutation,
|
type ClauseMutation,
|
||||||
type MechanicClause,
|
type MechanicClause,
|
||||||
type MechanicEventKind,
|
type MechanicEventKind,
|
||||||
type MechanicOutcome,
|
type MechanicOutcome,
|
||||||
type MechanicOutcomeScope,
|
type MechanicOutcomeScope,
|
||||||
|
type MechanicOutcomeTargeting,
|
||||||
type MechanicProgram,
|
type MechanicProgram,
|
||||||
type MechanicResource,
|
type MechanicResource,
|
||||||
type NumericExpression,
|
type NumericExpression,
|
||||||
@@ -198,6 +200,44 @@ export function registeredEventFields(event: MechanicEventKind) {
|
|||||||
return MECHANIC_EVENT_REGISTRY[event].fields ?? [];
|
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 = {
|
export type StatePoolThresholdComparison = {
|
||||||
pool: string;
|
pool: string;
|
||||||
operator: PredicateComparisonOperator;
|
operator: PredicateComparisonOperator;
|
||||||
@@ -258,6 +298,7 @@ export const CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES = {
|
|||||||
'maxStacks',
|
'maxStacks',
|
||||||
'targeting',
|
'targeting',
|
||||||
'descriptionSlot',
|
'descriptionSlot',
|
||||||
|
'probabilityPercent',
|
||||||
],
|
],
|
||||||
phase: ['on_event', 'after_event', 'on_enter', 'on_exit', 'while_active'],
|
phase: ['on_event', 'after_event', 'on_enter', 'on_exit', 'while_active'],
|
||||||
kind: [
|
kind: [
|
||||||
@@ -291,6 +332,25 @@ export const CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES = {
|
|||||||
continuous:
|
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.',
|
'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;
|
} as const;
|
||||||
const phases = new Set<OutcomePhase>(
|
const phases = new Set<OutcomePhase>(
|
||||||
CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES.phase,
|
CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES.phase,
|
||||||
@@ -366,11 +426,14 @@ function isReference(value: unknown): value is ValueReference {
|
|||||||
Boolean(value.path.trim()) &&
|
Boolean(value.path.trim()) &&
|
||||||
(value.timing === undefined ||
|
(value.timing === undefined ||
|
||||||
value.timing === 'pre_event' ||
|
value.timing === 'pre_event' ||
|
||||||
value.timing === 'current') &&
|
value.timing === 'current')
|
||||||
!(value.path === 'currentHealthPercent' && value.timing === 'pre_event')
|
|
||||||
);
|
);
|
||||||
case 'equipment':
|
case 'equipment':
|
||||||
return typeof value.path === 'string' && Boolean(value.path.trim());
|
return typeof value.path === 'string' && Boolean(value.path.trim());
|
||||||
|
case 'assigned_ability':
|
||||||
|
return (
|
||||||
|
value.path === 'active' || value.path === 'remainingDurationSeconds'
|
||||||
|
);
|
||||||
case 'attribute':
|
case 'attribute':
|
||||||
return (
|
return (
|
||||||
typeof value.attributeId === 'string' &&
|
typeof value.attributeId === 'string' &&
|
||||||
@@ -527,6 +590,17 @@ function isMutation(value: unknown): value is ClauseMutation {
|
|||||||
isExpression(value.value))
|
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 (
|
return (
|
||||||
['add', 'set', 'consume', 'derive'].includes(value.kind) &&
|
['add', 'set', 'consume', 'derive'].includes(value.kind) &&
|
||||||
typeof value.pool === 'string' &&
|
typeof value.pool === 'string' &&
|
||||||
@@ -724,30 +798,21 @@ function parseOutcome(
|
|||||||
) {
|
) {
|
||||||
errors.push(`${path} description slot must be a positive integer.`);
|
errors.push(`${path} description slot must be a positive integer.`);
|
||||||
}
|
}
|
||||||
if (value.targeting !== undefined) {
|
if (
|
||||||
if (
|
value.probabilityPercent !== undefined &&
|
||||||
!object(value.targeting) ||
|
(!finite(value.probabilityPercent) ||
|
||||||
value.targeting.kind !== 'within_radius' ||
|
value.probabilityPercent < 0 ||
|
||||||
!['allies', 'enemies'].includes(String(value.targeting.population)) ||
|
value.probabilityPercent > 100)
|
||||||
!finite(value.targeting.radiusMeters) ||
|
) {
|
||||||
value.targeting.radiusMeters <= 0
|
errors.push(`${path} probability must be from 0 through 100 percent.`);
|
||||||
) {
|
|
||||||
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.`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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)) {
|
if (!object(value.variants)) {
|
||||||
errors.push(`${path} variants must be an object.`);
|
errors.push(`${path} variants must be an object.`);
|
||||||
return null;
|
return null;
|
||||||
@@ -828,6 +893,22 @@ function parseClauses(
|
|||||||
if (candidate.weight !== undefined && !isExpression(candidate.weight)) {
|
if (candidate.weight !== undefined && !isExpression(candidate.weight)) {
|
||||||
errors.push(`${path} has an invalid weight expression.`);
|
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 (
|
if (
|
||||||
candidate.cooldownSeconds !== undefined &&
|
candidate.cooldownSeconds !== undefined &&
|
||||||
(!finite(candidate.cooldownSeconds) || candidate.cooldownSeconds <= 0)
|
(!finite(candidate.cooldownSeconds) || candidate.cooldownSeconds <= 0)
|
||||||
@@ -902,6 +983,15 @@ function programsForDraft(
|
|||||||
? {}
|
? {}
|
||||||
: { cooldownSeconds: clause.cooldownSeconds }),
|
: { cooldownSeconds: clause.cooldownSeconds }),
|
||||||
...(clause.weight === undefined ? {} : { weight: clause.weight }),
|
...(clause.weight === undefined ? {} : { weight: clause.weight }),
|
||||||
|
...(clause.procChancePercent === undefined
|
||||||
|
? {}
|
||||||
|
: { procChancePercent: clause.procChancePercent }),
|
||||||
|
...(clause.requiresAssignedAbilityActive === undefined
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
requiresAssignedAbilityActive:
|
||||||
|
clause.requiresAssignedAbilityActive,
|
||||||
|
}),
|
||||||
mutations: clause.mutations,
|
mutations: clause.mutations,
|
||||||
outcomes: clause.outcomes.map(
|
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', () => {
|
it('loads continuous restoration delivery metadata', () => {
|
||||||
const result = adaptConditionalEffectProgram(
|
const result = adaptConditionalEffectProgram(
|
||||||
{
|
{
|
||||||
@@ -713,7 +850,7 @@ describe('conditional-effect persistence adapter', () => {
|
|||||||
left: {
|
left: {
|
||||||
source: 'combat',
|
source: 'combat',
|
||||||
path: 'currentHealthPercent',
|
path: 'currentHealthPercent',
|
||||||
timing: 'current',
|
timing: 'pre_event',
|
||||||
},
|
},
|
||||||
operator: 'lt',
|
operator: 'lt',
|
||||||
right: 25,
|
right: 25,
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export type ConditionalEffectClauseRow = {
|
|||||||
event_kind?: DatabaseValue;
|
event_kind?: DatabaseValue;
|
||||||
recipe_key?: DatabaseValue;
|
recipe_key?: DatabaseValue;
|
||||||
parameters_json: DatabaseValue;
|
parameters_json: DatabaseValue;
|
||||||
|
recipe_parameters_json?: DatabaseValue;
|
||||||
conditions_json: DatabaseValue;
|
conditions_json: DatabaseValue;
|
||||||
reducer_json: DatabaseValue;
|
reducer_json: DatabaseValue;
|
||||||
weight_expression_json?: DatabaseValue;
|
weight_expression_json?: DatabaseValue;
|
||||||
@@ -85,6 +86,7 @@ export type ConditionalEffectOutcomeRow = {
|
|||||||
delivery_mode?: DatabaseValue;
|
delivery_mode?: DatabaseValue;
|
||||||
delivery_duration_seconds?: DatabaseValue;
|
delivery_duration_seconds?: DatabaseValue;
|
||||||
targeting_json?: DatabaseValue;
|
targeting_json?: DatabaseValue;
|
||||||
|
probability_percent?: DatabaseValue;
|
||||||
value_scaling: DatabaseValue;
|
value_scaling: DatabaseValue;
|
||||||
unit: DatabaseValue;
|
unit: DatabaseValue;
|
||||||
damage_basis: DatabaseValue;
|
damage_basis: DatabaseValue;
|
||||||
@@ -205,14 +207,16 @@ function isValueReference(value: unknown) {
|
|||||||
if (value.source === 'event' || value.source === 'equipment') {
|
if (value.source === 'event' || value.source === 'equipment') {
|
||||||
return typeof value.path === 'string' && Boolean(value.path.trim());
|
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') {
|
if (value.source === 'combat') {
|
||||||
return (
|
return (
|
||||||
typeof value.path === 'string' &&
|
typeof value.path === 'string' &&
|
||||||
Boolean(value.path.trim()) &&
|
Boolean(value.path.trim()) &&
|
||||||
(value.timing === undefined ||
|
(value.timing === undefined ||
|
||||||
value.timing === 'pre_event' ||
|
value.timing === 'pre_event' ||
|
||||||
value.timing === 'current') &&
|
value.timing === 'current')
|
||||||
!(value.path === 'currentHealthPercent' && value.timing === 'pre_event')
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (value.source === 'attribute') {
|
if (value.source === 'attribute') {
|
||||||
@@ -313,6 +317,19 @@ function isMutation(value: unknown): value is ClauseMutation {
|
|||||||
isNumericExpression(value.value))
|
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 (
|
return (
|
||||||
['add', 'set', 'consume', 'derive'].includes(value.kind) &&
|
['add', 'set', 'consume', 'derive'].includes(value.kind) &&
|
||||||
typeof value.pool === 'string' &&
|
typeof value.pool === 'string' &&
|
||||||
@@ -731,6 +748,17 @@ function parseOutcome(
|
|||||||
if (delivery === 'instant' && durationSeconds !== null) {
|
if (delivery === 'instant' && durationSeconds !== null) {
|
||||||
errors.push(`${path} instant delivery cannot set a duration.`);
|
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;
|
let targeting: MechanicOutcome['targeting'] | undefined;
|
||||||
if (
|
if (
|
||||||
row.targeting_json !== null &&
|
row.targeting_json !== null &&
|
||||||
@@ -766,6 +794,7 @@ function parseOutcome(
|
|||||||
? { delivery, durationSeconds }
|
? { delivery, durationSeconds }
|
||||||
: {}),
|
: {}),
|
||||||
...(targeting ? { targeting } : {}),
|
...(targeting ? { targeting } : {}),
|
||||||
|
...(probabilityPercent === null ? {} : { probabilityPercent }),
|
||||||
...(kind === 'attribute_modifier' && nullableText(row.stat_attribute_id)
|
...(kind === 'attribute_modifier' && nullableText(row.stat_attribute_id)
|
||||||
? { attributeId: text(row.stat_attribute_id) }
|
? { attributeId: text(row.stat_attribute_id) }
|
||||||
: {}),
|
: {}),
|
||||||
@@ -903,7 +932,7 @@ export function adaptConditionalEffectProgram(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const parametersJson = parseJson(
|
const parametersJson = parseJson(
|
||||||
row.parameters_json,
|
row.recipe_parameters_json ?? row.parameters_json,
|
||||||
`${path} parameters`,
|
`${path} parameters`,
|
||||||
errors,
|
errors,
|
||||||
);
|
);
|
||||||
@@ -976,6 +1005,25 @@ export function adaptConditionalEffectProgram(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const recipeKey = nullableText(row.recipe_key);
|
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({
|
clauses.push({
|
||||||
id,
|
id,
|
||||||
label: text(row.label) || text(row.clause_key) || undefined,
|
label: text(row.label) || text(row.clause_key) || undefined,
|
||||||
@@ -989,6 +1037,10 @@ export function adaptConditionalEffectProgram(
|
|||||||
? {}
|
? {}
|
||||||
: { cooldownSeconds: number(row.cooldown_seconds) as number }),
|
: { cooldownSeconds: number(row.cooldown_seconds) as number }),
|
||||||
...(weight === undefined ? {} : { weight }),
|
...(weight === undefined ? {} : { weight }),
|
||||||
|
...(typeof procChancePercent === 'number' ? { procChancePercent } : {}),
|
||||||
|
...(typeof requiresAssignedAbilityActive === 'boolean'
|
||||||
|
? { requiresAssignedAbilityActive }
|
||||||
|
: {}),
|
||||||
mutations: mutationsJson.value,
|
mutations: mutationsJson.value,
|
||||||
...(clauseOutcomes.length ? { outcomes: clauseOutcomes } : {}),
|
...(clauseOutcomes.length ? { outcomes: clauseOutcomes } : {}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,6 +19,276 @@ const custom = (
|
|||||||
inputs,
|
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', () => {
|
describe('structured mechanic runtime', () => {
|
||||||
it('advertises the authoritative weapon-hit target and headshot fields', () => {
|
it('advertises the authoritative weapon-hit target and headshot fields', () => {
|
||||||
expect(MECHANIC_EVENT_REGISTRY.weapon_hit).toMatchObject({
|
expect(MECHANIC_EVENT_REGISTRY.weapon_hit).toMatchObject({
|
||||||
@@ -32,6 +302,11 @@ describe('structured mechanic runtime', () => {
|
|||||||
},
|
},
|
||||||
{ path: 'values.headshot', label: 'Headshot', type: 'boolean' },
|
{ path: 'values.headshot', label: 'Headshot', type: 'boolean' },
|
||||||
{ path: 'values.critical', label: 'Critical hit', type: 'boolean' },
|
{ path: 'values.critical', label: 'Critical hit', type: 'boolean' },
|
||||||
|
{
|
||||||
|
path: 'values.aiming',
|
||||||
|
label: 'Aiming down sights',
|
||||||
|
type: 'boolean',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'values.behindCover',
|
path: 'values.behindCover',
|
||||||
label: 'Agent behind cover',
|
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', () => {
|
it('executes imported talent trigger families as first-class events', () => {
|
||||||
const eventKinds: MechanicEventKind[] = [
|
const eventKinds: MechanicEventKind[] = [
|
||||||
'ally_downed',
|
'ally_downed',
|
||||||
@@ -2404,13 +2834,13 @@ describe('mechanic validation and pseudo-script', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects pre-event timing for the derived current Health percentage', () => {
|
it('evaluates pre-event timing for the derived current Health percentage', () => {
|
||||||
const invalid: MechanicProgram = {
|
const program: MechanicProgram = {
|
||||||
id: 'invalid-health-percent',
|
id: 'pre-event-health-percent',
|
||||||
name: 'Invalid Health Percent',
|
name: 'Pre-event Health Percent',
|
||||||
clauses: [
|
clauses: [
|
||||||
{
|
{
|
||||||
id: 'invalid',
|
id: 'full-health-before-event',
|
||||||
when: 'tick',
|
when: 'tick',
|
||||||
if: {
|
if: {
|
||||||
kind: 'compare',
|
kind: 'compare',
|
||||||
@@ -2419,25 +2849,32 @@ describe('mechanic validation and pseudo-script', () => {
|
|||||||
path: 'currentHealthPercent',
|
path: 'currentHealthPercent',
|
||||||
timing: 'pre_event',
|
timing: 'pre_event',
|
||||||
},
|
},
|
||||||
operator: 'lt',
|
operator: 'gte',
|
||||||
right: 50,
|
right: 100,
|
||||||
},
|
},
|
||||||
reducer: { kind: 'none' },
|
reducer: { kind: 'none' },
|
||||||
mutations: [],
|
mutations: [],
|
||||||
|
outcomes: [
|
||||||
|
{
|
||||||
|
id: 'matched',
|
||||||
|
label: 'Matched',
|
||||||
|
phase: 'on_event',
|
||||||
|
kind: 'marker',
|
||||||
|
scope: 'self',
|
||||||
|
basis: 'flat',
|
||||||
|
value: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
const result = validateMechanicProgram(invalid);
|
expect(validateMechanicProgram(program)).toMatchObject({ ok: true });
|
||||||
expect(result.ok).toBe(false);
|
expect(
|
||||||
if (!result.ok) {
|
evaluateMechanicProgram(program, [{ kind: 'tick', at: 0 }], custom(1), {
|
||||||
expect(result.errors).toEqual(
|
maxHealth: 100,
|
||||||
expect.arrayContaining([
|
currentHealth: 100,
|
||||||
expect.stringContaining(
|
}).outcomes.map((outcome) => outcome.outcomeId),
|
||||||
'current Health percentage must use current timing',
|
).toEqual(['matched']);
|
||||||
),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('formats nested conditions and state operations deterministically', () => {
|
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 ALLIES WITHIN 40m');
|
||||||
expect(result.script).toContain('TO ENEMIES WITHIN 20m');
|
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>
|
<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}
|
||||||
{/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>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="account-panel" role="tabpanel">
|
<div class="account-panel" role="tabpanel">
|
||||||
@@ -183,6 +187,6 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
<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}
|
.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}}
|
@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>
|
</style>
|
||||||
|
|||||||
@@ -23,7 +23,9 @@
|
|||||||
let coverUploading = $state(false);
|
let coverUploading = $state(false);
|
||||||
let initialized = $state(false);
|
let initialized = $state(false);
|
||||||
let lastContent = '';
|
let lastContent = '';
|
||||||
let saveSequence = 0;
|
let saveTimer: number | undefined;
|
||||||
|
let saveInFlight = false;
|
||||||
|
let saveQueued = false;
|
||||||
const groupedTags = $derived.by(() => {
|
const groupedTags = $derived.by(() => {
|
||||||
const groups: Record<string, ComposerTag[]> = {};
|
const groups: Record<string, ComposerTag[]> = {};
|
||||||
for (const tag of data.tags as ComposerTag[]) (groups[tag.group] ??= []).push(tag);
|
for (const tag of data.tags as ComposerTag[]) (groups[tag.group] ??= []).push(tag);
|
||||||
@@ -35,6 +37,40 @@
|
|||||||
coverImageId, coverAlt, coverCaption, version };
|
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(() => {
|
$effect(() => {
|
||||||
const content = JSON.stringify({ title, deck, document, selectedTags, channel, coverImageId, coverAlt, coverCaption });
|
const content = JSON.stringify({ title, deck, document, selectedTags, channel, coverImageId, coverAlt, coverCaption });
|
||||||
if (!initialized) {
|
if (!initialized) {
|
||||||
@@ -44,29 +80,13 @@
|
|||||||
}
|
}
|
||||||
if (content === lastContent) return;
|
if (content === lastContent) return;
|
||||||
lastContent = content;
|
lastContent = content;
|
||||||
const sequence = ++saveSequence;
|
saveQueued = true;
|
||||||
saveState = 'saving';
|
saveState = 'saving';
|
||||||
const timer = window.setTimeout(async () => {
|
if (!saveInFlight) scheduleSave();
|
||||||
try {
|
return () => {
|
||||||
const response = await fetch('/api/articles/draft', {
|
if (saveTimer !== undefined) window.clearTimeout(saveTimer);
|
||||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload())
|
saveTimer = undefined;
|
||||||
});
|
};
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function toggleTag(id: string) {
|
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>
|
<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">
|
<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}/>
|
<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>
|
</PendingForm>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,16 @@
|
|||||||
function link(mark: ArticleMark) {
|
function link(mark: ArticleMark) {
|
||||||
return mark.type === 'link' ? mark.attrs?.href ?? '#' : '#';
|
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>
|
</script>
|
||||||
|
|
||||||
{#snippet renderText(text: string, marks: ArticleMark[], index = 0)}
|
{#snippet renderText(text: string, marks: ArticleMark[], index = 0)}
|
||||||
@@ -32,7 +42,7 @@
|
|||||||
{#if mark.type === 'bold'}<strong>{@render renderText(text, marks, index + 1)}</strong>
|
{#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 === 'italic'}<em>{@render renderText(text, marks, index + 1)}</em>
|
||||||
{:else if mark.type === 'code'}<code>{@render renderText(text, marks, index + 1)}</code>
|
{: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}
|
{/if}
|
||||||
{/snippet}
|
{/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' && Number(node.attrs?.level) === 2}<h2>{@render children(node)}</h2>
|
||||||
{:else if node.type === 'heading'}<h3>{@render children(node)}</h3>
|
{:else if node.type === 'heading'}<h3>{@render children(node)}</h3>
|
||||||
{:else if node.type === 'bulletList'}<ul>{@render children(node)}</ul>
|
{: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 === 'listItem'}<li>{@render children(node)}</li>
|
||||||
{:else if node.type === 'blockquote'}<blockquote>{@render children(node)}</blockquote>
|
{:else if node.type === 'blockquote'}<blockquote>{@render children(node)}</blockquote>
|
||||||
{:else if node.type === 'horizontalRule'}<hr/>
|
{:else if node.type === 'horizontalRule'}<hr/>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import AgentIdentityLink from '$lib/components/AgentIdentityLink.svelte';
|
||||||
import ArticleContent from '$lib/components/ArticleContent.svelte';
|
import ArticleContent from '$lib/components/ArticleContent.svelte';
|
||||||
import ArticleDiscussion from '$lib/components/ArticleDiscussion.svelte';
|
import ArticleDiscussion from '$lib/components/ArticleDiscussion.svelte';
|
||||||
import AgentIdentityLink from '$lib/components/AgentIdentityLink.svelte';
|
|
||||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
let { data, form = null } = $props();
|
let { data, form = null } = $props();
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
<header class="article-reader-head">
|
<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>
|
<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>
|
<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.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}
|
{#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>
|
</header>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
items,
|
items,
|
||||||
value = $bindable(''),
|
value = $bindable(''),
|
||||||
label,
|
label,
|
||||||
placeholder = 'Search available items…',
|
placeholder = 'Search…',
|
||||||
disabledIds = [],
|
disabledIds = [],
|
||||||
onchange
|
onchange
|
||||||
}: {
|
}: {
|
||||||
@@ -27,19 +27,19 @@
|
|||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
<button type="button" class="builder-picker-current" aria-expanded={open} onclick={() => open = !open}>
|
<button type="button" class="builder-picker-current" aria-expanded={open} onclick={() => open = !open}>
|
||||||
<ItemIcon src={selected?.iconUrl} alt=""/>
|
<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>
|
</button>
|
||||||
{#if open}
|
{#if open}
|
||||||
<div class="builder-picker-panel">
|
<div class="builder-picker-panel">
|
||||||
<input type="search" bind:value={query} {placeholder} aria-label={placeholder}/>
|
<input type="search" bind:value={query} {placeholder} aria-label={placeholder}/>
|
||||||
<div>
|
<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}
|
{#each results as item}
|
||||||
<button type="button" class:selected={item.id === value} disabled={disabledIds.includes(item.id)} onclick={() => choose(item.id)}>
|
<button type="button" class:selected={item.id === value} disabled={disabledIds.includes(item.id)} onclick={() => choose(item.id)}>
|
||||||
<ItemIcon src={item.iconUrl} alt=""/>
|
<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>
|
<span><b>{item.name}</b><small>{item.category ?? 'AVAILABLE'}</small></span><em>{item.id === value ? 'EQUIPPED' : disabledIds.includes(item.id) ? 'CONFLICT' : 'SELECT'}</em>
|
||||||
</button>
|
</button>
|
||||||
{:else}<p>NO MATCHING ITEMS</p>{/each}
|
{:else}<p>NO MATCHES</p>{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@
|
|||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let query = $state('');
|
let query = $state('');
|
||||||
|
let open = $state(false);
|
||||||
|
const selected = $derived(items.find((item) => item.id === value));
|
||||||
const visible = $derived(items.filter((item) =>
|
const visible = $derived(items.filter((item) =>
|
||||||
!query.trim() || `${item.name} ${item.category ?? ''}`.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase())
|
!query.trim() || `${item.name} ${item.category ?? ''}`.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase())
|
||||||
));
|
));
|
||||||
@@ -31,26 +33,33 @@
|
|||||||
function choose(id: string) {
|
function choose(id: string) {
|
||||||
value = id;
|
value = id;
|
||||||
onchange?.(id);
|
onchange?.(id);
|
||||||
|
if (searchable) {
|
||||||
|
open = false;
|
||||||
|
query = '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<fieldset class="builder-radio-grid" class:icon-only={iconOnly} style={`--builder-radio-columns:${Math.max(1, columns)}`}>
|
<fieldset class="builder-radio-grid" class:icon-only={iconOnly} style={`--builder-radio-columns:${Math.max(1, columns)}`}>
|
||||||
<legend>{label}</legend>
|
<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()}`}/>
|
<input class="builder-radio-search" type="search" bind:value={query} placeholder="SEARCH AVAILABLE OPTIONS" aria-label={`Search ${label.toLocaleLowerCase()}`}/>
|
||||||
{/if}
|
{/if}
|
||||||
<div>
|
{#if !searchable || open}
|
||||||
{#each visible as item}
|
<div>
|
||||||
<label title={item.name}>
|
{#each visible as item}
|
||||||
<input type="radio" {name} value={item.id} checked={value === item.id} {required} aria-label={item.name} onchange={() => choose(item.id)}/>
|
<label title={item.name}>
|
||||||
<span>
|
<input type="radio" {name} value={item.id} checked={value === item.id} {required} aria-label={item.name} onchange={() => choose(item.id)}/>
|
||||||
<ItemIcon src={item.iconUrl} alt=""/>
|
<span>
|
||||||
<b>{item.name}</b>
|
<ItemIcon src={item.iconUrl} alt=""/>
|
||||||
{#if item.category}<small>{item.category}</small>{/if}
|
<b>{item.name}</b>
|
||||||
</span>
|
{#if item.category}<small>{item.category}</small>{/if}
|
||||||
</label>
|
</span>
|
||||||
{:else}
|
</label>
|
||||||
<p>NO MATCHING OPTIONS</p>
|
{:else}
|
||||||
{/each}
|
<p>NO MATCHING OPTIONS</p>
|
||||||
</div>
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import type { GameIconName } from '$lib/game-icons';
|
import type { GameIconName } from '$lib/game-icons';
|
||||||
import type { DatabaseItem } from '$lib/database/items';
|
import type { DatabaseItem } from '$lib/database/items';
|
||||||
import MarkdownContent from './MarkdownContent.svelte';
|
import MarkdownContent from './MarkdownContent.svelte';
|
||||||
|
import WeaponModSlotIcon from './WeaponModSlotIcon.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
item,
|
item,
|
||||||
@@ -16,9 +17,21 @@
|
|||||||
hideDiscussionUtility?: boolean;
|
hideDiscussionUtility?: boolean;
|
||||||
anchor?: boolean;
|
anchor?: boolean;
|
||||||
} = $props();
|
} = $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>
|
</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>
|
<header>
|
||||||
{#if item.renderUrl}<img class="database-item-render" src={item.renderUrl} alt="" loading="lazy"/>{/if}
|
{#if item.renderUrl}<img class="database-item-render" src={item.renderUrl} alt="" loading="lazy"/>{/if}
|
||||||
<div class="database-item-icon">
|
<div class="database-item-icon">
|
||||||
@@ -31,13 +44,38 @@
|
|||||||
{#if item.headerBadges?.length || item.headerBadge || item.headerMetaIcon}
|
{#if item.headerBadges?.length || item.headerBadge || item.headerMetaIcon}
|
||||||
<div class="database-header-status">
|
<div class="database-header-status">
|
||||||
{#if item.headerMetaIcon}<GameIcon name={item.headerMetaIcon}/>{/if}
|
{#if item.headerMetaIcon}<GameIcon name={item.headerMetaIcon}/>{/if}
|
||||||
{#each item.headerBadges ?? [] as badge}<span class="database-header-badge">{badge}</span>{/each}
|
{#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">{item.headerBadge}</span>{/if}
|
{#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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</header>
|
</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}
|
{#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}
|
{#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>
|
<div class="database-item-lines">{#each item.lines as line}<div><span>{line.label}</span><b>{line.value}</b></div>{/each}</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -53,7 +91,7 @@
|
|||||||
<section><span>TIER I</span><p>{item.description ?? 'Description is not available.'}</p></section>
|
<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>
|
<section><span>TIER II</span><p>{item.secondaryDescription ?? 'Description is not available.'}</p></section>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else if !item.protocolPreview}
|
||||||
{#if item.description}<p class:data-description={item.descriptionStyle === 'data'}>{item.description}</p>{/if}
|
{#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 item.secondaryDescription}<p class="secondary-description" class:data-description={item.descriptionStyle === 'data'}>{item.secondaryDescription}</p>{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -14,4 +14,4 @@
|
|||||||
const resolvedAlt = $derived(src || !alt ? alt : `${alt} — icon awaiting contribution`);
|
const resolvedAlt = $derived(src || !alt ? alt : `${alt} — icon awaiting contribution`);
|
||||||
</script>
|
</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',
|
gear_max: 'gearMax',
|
||||||
weapon_min: 'weaponMin',
|
weapon_min: 'weaponMin',
|
||||||
weapon_max: 'weaponMax',
|
weapon_max: 'weaponMax',
|
||||||
skill_chip_min: 'chipMin',
|
|
||||||
skill_chip_max: 'chipMax',
|
|
||||||
cap_value: 'capValue'
|
cap_value: 'capValue'
|
||||||
};
|
};
|
||||||
return proposed[names[key] ?? key];
|
return proposed[names[key] ?? key];
|
||||||
@@ -46,6 +44,8 @@
|
|||||||
);
|
);
|
||||||
const specializationRecord = $derived((currentSnapshot?.record ?? null) as Record<string, unknown> | null);
|
const specializationRecord = $derived((currentSnapshot?.record ?? null) as Record<string, unknown> | null);
|
||||||
const skillChipRecord = $derived((currentSnapshot?.record ?? null) as Record<string, unknown> | null);
|
const 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([
|
const skillChipFields = $derived([
|
||||||
{ label: 'NAME', current: skillChipRecord?.name, proposed: proposed.name },
|
{ label: 'NAME', current: skillChipRecord?.name, proposed: proposed.name },
|
||||||
{ label: 'SPECIALIZATION ABILITY', current: skillChipRecord?.specialization_ability_id, proposed: proposed.abilityId },
|
{ 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: 'SPECIALIZATION', current: specializationRecord?.specialization_id, proposed: proposed.specializationId },
|
||||||
{ label: 'DURATION · SECONDS', current: specializationRecord?.duration_seconds, proposed: proposed.durationSeconds },
|
{ label: 'DURATION · SECONDS', current: specializationRecord?.duration_seconds, proposed: proposed.durationSeconds },
|
||||||
{ label: 'COOLDOWN · SECONDS', current: specializationRecord?.cooldown_seconds, proposed: proposed.cooldownSeconds },
|
{ 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 }
|
{ label: 'DESCRIPTION', current: specializationRecord?.description, proposed: proposed.description }
|
||||||
]
|
]
|
||||||
: action === 'saveCombatEffect'
|
: action === 'saveCombatEffect'
|
||||||
@@ -256,7 +257,7 @@
|
|||||||
{#each [['type','TYPE'],['category','CATEGORY'],['calculation_type','CALCULATION'],['unit','UNIT']] as field}
|
{#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>
|
<div class="preview-field"><span>{field[1]}</span><strong>{shown(attributeValue('current', field[0]))}</strong></div>
|
||||||
{/each}
|
{/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>
|
<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}
|
{/each}
|
||||||
<div class="preview-field"><span>OPTIONAL CAP</span><strong>{shown(attributeValue('current', 'cap_value'))}</strong></div>
|
<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}
|
{#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>
|
<div class="preview-field"><span>{field[1]}</span><strong>{shown(attributeValue('proposed', field[0]))}</strong></div>
|
||||||
{/each}
|
{/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>
|
<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}
|
{/each}
|
||||||
<div class="preview-field"><span>OPTIONAL CAP</span><strong>{shown(attributeValue('proposed', 'cap_value'))}</strong></div>
|
<div class="preview-field"><span>OPTIONAL CAP</span><strong>{shown(attributeValue('proposed', 'cap_value'))}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</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'}
|
{:else if module === 'skillchips' && action === 'save'}
|
||||||
<div class="proposal-compare-grid">
|
<div class="proposal-compare-grid">
|
||||||
<article class="proposal-editor-preview before">
|
<article class="proposal-editor-preview before">
|
||||||
@@ -287,7 +299,7 @@
|
|||||||
{#if skillChipRecord}
|
{#if skillChipRecord}
|
||||||
<div class="proposal-field-summary">
|
<div class="proposal-field-summary">
|
||||||
{#each skillChipFields as field}<div><span>{field.label}</span><strong>{shown(field.current)}</strong></div>{/each}
|
{#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>
|
||||||
<div class="proposal-structured-list">
|
<div class="proposal-structured-list">
|
||||||
<header><span>3-PIECE CONDITIONAL MECHANIC</span><b>TYPED RULE</b></header>
|
<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>
|
<header><span>PROPOSED · SKILL CHIP</span><b>AFTER</b></header>
|
||||||
<div class="proposal-field-summary">
|
<div class="proposal-field-summary">
|
||||||
{#each skillChipFields as field}<div><span>{field.label}</span><strong>{shown(field.proposed)}</strong></div>{/each}
|
{#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>
|
||||||
<div class="proposal-structured-list">
|
<div class="proposal-structured-list">
|
||||||
<header><span>3-PIECE CONDITIONAL MECHANIC</span><b>TYPED RULE</b></header>
|
<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]));
|
&& Math.floor(Number(active.split(':')[1]) / 3) === Number(cell.id.split(':')[1]));
|
||||||
|
|
||||||
const issueLabel = (issue: string) => {
|
const issueLabel = (issue: string) => {
|
||||||
if (/not configured|required/i.test(issue)) return 'ACTION REQUIRED';
|
if (/not configured|required|select|choose/i.test(issue)) return 'REQUIRED';
|
||||||
if (/incomplete/i.test(issue)) return 'DATA INCOMPLETE';
|
if (/incomplete/i.test(issue)) return 'INCOMPLETE';
|
||||||
return 'CHECK CONFIGURATION';
|
return 'CHECK';
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="loadout-matrix" aria-label="Loadout matrix">
|
<section class="loadout-matrix" aria-label="Loadout matrix">
|
||||||
<header>
|
<header>
|
||||||
<span>LOADOUT MATRIX</span>
|
<span>LOADOUT</span>
|
||||||
<b class:ready={configured}>{configured ? 'COMBAT READY' : 'CONFIGURATION INCOMPLETE'}</b>
|
<b class:ready={configured}>{configured ? 'COMPLETE' : 'INCOMPLETE'}</b>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="matrix-section doctrine-section">
|
<div class="matrix-section doctrine-section">
|
||||||
<small class="section-label">DOCTRINE · ROOT CHOICE</small>
|
<small class="section-label">SPECIALIZATION FOCUS</small>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="doctrine-cell"
|
class="doctrine-cell"
|
||||||
@@ -60,27 +60,15 @@
|
|||||||
onclick={() => onselect(doctrine.id)}
|
onclick={() => onselect(doctrine.id)}
|
||||||
>
|
>
|
||||||
<ItemIcon src={doctrine.iconUrl} alt="" className="matrix-icon" />
|
<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>
|
<i aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
<div class="supply-line"><span>SUPPLIES 3 ABILITIES</span></div>
|
<div class="supply-line"><span>ABILITIES</span></div>
|
||||||
<div class="ability-cells">
|
<div class="ability-cells">
|
||||||
{#each abilities as cell}
|
{#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)}>
|
<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" />
|
<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>
|
||||||
</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>
|
|
||||||
<i aria-hidden="true"></i>
|
<i aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -88,12 +76,25 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="matrix-section">
|
<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">
|
<div class="equipment-cells gear-cells">
|
||||||
{#each gear as cell}
|
{#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)}>
|
<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" />
|
<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>
|
<i aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -101,10 +102,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="matrix-section protocol-section">
|
<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)}>
|
<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" />
|
<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>
|
<i aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -112,21 +113,20 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.loadout-matrix{min-width:0;border:1px solid var(--shd-border);background:var(--shd-surface)}
|
.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)}
|
.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}
|
.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}
|
.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,.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}
|
.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}
|
: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: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}
|
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}
|
.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}
|
.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}
|
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) and (min-width:651px){.ability-cells{grid-template-columns:repeat(3,minmax(0,1fr))}.ability-cells button{min-height:68px}}
|
||||||
@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}.gear-cells{grid-template-columns:1fr}}
|
||||||
@media(max-width:520px){.ability-cells{grid-template-columns:1fr}.equipment-cells{grid-template-columns:1fr 1fr}}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -37,10 +37,9 @@
|
|||||||
<section class:page={layout === 'page'} class="loadout-known-stats" aria-labelledby={headingId}>
|
<section class:page={layout === 'page'} class="loadout-known-stats" aria-labelledby={headingId}>
|
||||||
<div class="summary-head">
|
<div class="summary-head">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow"><span></span> KNOWN EQUIPMENT TOTALS</p>
|
<p class="eyebrow"><span></span> KNOWN STATS</p>
|
||||||
<h2 id={headingId}>Agent stats</h2>
|
<h2 id={headingId}>Agent stats</h2>
|
||||||
</div>
|
</div>
|
||||||
<b>{summary.stats.length}</b>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-core">
|
<div class="summary-core">
|
||||||
{#each ['Firepower', 'Toughness', 'Engineering'] as name}
|
{#each ['Firepower', 'Toughness', 'Engineering'] as name}
|
||||||
@@ -53,49 +52,57 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="summary-scroll">
|
<div class="summary-scroll">
|
||||||
<div class="summary-section">
|
<div class="summary-section">
|
||||||
<header><span>COMBINED ATTRIBUTES</span><b>{summary.stats.length}</b></header>
|
<header><span>ATTRIBUTES</span><b>{summary.stats.length}</b></header>
|
||||||
{#each summary.stats as stat}
|
{#each summary.stats as stat}
|
||||||
<div class="summary-stat">
|
<div class="summary-stat">
|
||||||
<span>{attribute(stat.id)?.name}<small>{attribute(stat.id)?.category} · {stat.sourceCount} SOURCE{stat.sourceCount === 1 ? '' : 'S'}</small></span>
|
<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>
|
<b>{unitValue(stat.value, stat.id)}</b>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p>Equip and configure items to calculate their known Attributes.</p>
|
<p>Equip items to see known Attributes.</p>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-section">
|
<div class="summary-section">
|
||||||
<header><span>ACTIVE SET BONUSES</span><b>{summary.activeBonuses.length}</b></header>
|
<header><span>SET BONUSES</span><b>{summary.activeBonuses.length}</b></header>
|
||||||
{#each summary.activeBonuses as bonus}
|
{#each summary.activeBonuses as bonus}
|
||||||
<div class="summary-bonus">
|
<div class="summary-bonus">
|
||||||
<span><b>{bonus.name}</b><small>{bonus.pieces}-PIECE · {bonus.count} EQUIPPED</small></span>
|
<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>
|
<em>+{unitValue(bonus.value, bonus.statId)} {attribute(bonus.statId)?.name}</em>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p>Matching Brand Sets and Skill Chips activate bonuses here.</p>
|
<p>No active bonuses.</p>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-section">
|
<div class="summary-section">
|
||||||
<header><span>EQUIPPED TALENTS</span><b>{summary.equippedTalents.length}</b></header>
|
<header><span>TALENTS</span><b>{summary.equippedTalents.length}</b></header>
|
||||||
{#each summary.equippedTalents as equipped}
|
{#each summary.equippedTalents as equipped}
|
||||||
<div class="summary-talent">
|
<div class="summary-talent">
|
||||||
<span>
|
<details open={layout === 'page'}>
|
||||||
<b>{equipped.custom?.name ?? talent(equipped.id)?.name ?? 'Talent'}</b>
|
<summary><b>{equipped.custom?.name ?? talent(equipped.id)?.name ?? 'Talent'}</b><small>{equipped.source}{equipped.tierLabel ? ` · ${equipped.tierLabel}` : ''}</small></summary>
|
||||||
<small>{equipped.source} · CONDITIONAL EFFECT NOT INCLUDED</small>
|
<p>{equipped.description}</p>
|
||||||
<em>{equipped.description}</em>
|
|
||||||
{#if equipped.id && talentCooldown(equipped.id)}<i class="talent-cooldown">{talentCooldown(equipped.id)}</i>{/if}
|
{#if equipped.id && talentCooldown(equipped.id)}<i class="talent-cooldown">{talentCooldown(equipped.id)}</i>{/if}
|
||||||
</span>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p>Selected equipment Talents appear here.</p>
|
<p>No Talents equipped.</p>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="known-stats-note">Totals reflect this saved loadout and the current canonical item definitions. They include fixed values, rolls, inherited profiles, enabled augments, set bonuses, Protocol effects, and static Specialization rank effects. Conditional Talent outcomes are reserved for the advanced combat model.</p>
|
<p class="known-stats-note">Known values only. Advanced effects are planned.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.loadout-known-stats{background:var(--shd-surface)}
|
.loadout-known-stats{background:var(--shd-surface)}
|
||||||
.summary-talent{grid-template-columns:1fr;cursor:default}
|
.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}
|
.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{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-scroll{max-height:none;display:grid;grid-template-columns:repeat(3,minmax(0,1fr))}
|
||||||
|
|||||||
@@ -10,20 +10,20 @@
|
|||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const capabilities = [
|
const capabilities = [
|
||||||
{ label: 'WEAPON OUTPUT', detail: 'Hit values, burst DPS, sustained DPS, and reload-aware firing cycles' },
|
{ label: 'WEAPON OUTPUT', detail: 'Hits and firing cycles' },
|
||||||
{ label: 'SKILL SIMULATION', detail: 'Ability cadence, target counts, critical hits, and multi-shot behavior' },
|
{ label: 'SKILL SIMULATION', detail: 'Timing and target caps' },
|
||||||
{ label: 'ENCOUNTER MODEL', detail: 'Duration, accuracy, movement, target flow, rotations, and downtime' },
|
{ label: 'ENCOUNTER MODEL', detail: 'Duration and rotations' },
|
||||||
{ label: 'CONDITIONAL EFFECTS', detail: 'Talent triggers, stack buildup, cooldowns, uptime, and resource changes' }
|
{ label: 'CONDITIONAL EFFECTS', detail: 'Triggers and uptime' }
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class:compact={variant === 'compact'} class="planned-combat-model" aria-labelledby={headingId}>
|
<section class:compact={variant === 'compact'} class="planned-combat-model" aria-labelledby={headingId}>
|
||||||
<header>
|
<header>
|
||||||
<div><span>ADVANCED COMBAT MODEL</span>{#if headingLevel === 'h2'}<h2 id={headingId}>DPS calculations</h2>{:else}<h3 id={headingId}>DPS calculations</h3>{/if}</div>
|
<div><span>COMBAT MODEL</span>{#if headingLevel === 'h2'}<h2 id={headingId}>DPS calculations</h2>{:else}<h3 id={headingId}>DPS calculations</h3>{/if}</div>
|
||||||
<b>COMING SOON</b>
|
<b>PLANNED</b>
|
||||||
</header>
|
</header>
|
||||||
{#if variant === 'full'}
|
{#if variant === 'full'}
|
||||||
<p>Known equipment totals remain available now. Advanced damage projections will return after their calculation rules and scenario controls are ready for player use.</p>
|
<p>Known stats are live. Damage projections are planned.</p>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="planned-capabilities" aria-label="Planned combat-model capabilities">
|
<div class="planned-capabilities" aria-label="Planned combat-model capabilities">
|
||||||
{#each capabilities as capability}
|
{#each capabilities as capability}
|
||||||
|
|||||||
@@ -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,
|
blankConditionalMechanicDraft,
|
||||||
currentHealthPercentBandPredicate,
|
currentHealthPercentBandPredicate,
|
||||||
eventFieldComparisonPredicate,
|
eventFieldComparisonPredicate,
|
||||||
|
mechanicTargetingForMode,
|
||||||
|
mechanicTargetingMode,
|
||||||
parseConditionalMechanicDraft,
|
parseConditionalMechanicDraft,
|
||||||
readCurrentHealthPercentBand,
|
readCurrentHealthPercentBand,
|
||||||
readEventFieldComparison,
|
readEventFieldComparison,
|
||||||
@@ -21,6 +23,7 @@
|
|||||||
type ConditionalMechanicPoolDraft,
|
type ConditionalMechanicPoolDraft,
|
||||||
type EditablePredicateScalar,
|
type EditablePredicateScalar,
|
||||||
type EventFieldComparison,
|
type EventFieldComparison,
|
||||||
|
type MechanicTargetingMode,
|
||||||
type PredicateComparisonOperator,
|
type PredicateComparisonOperator,
|
||||||
type StatePoolThresholdComparison
|
type StatePoolThresholdComparison
|
||||||
} from '$lib/calculations/mechanic-editor';
|
} from '$lib/calculations/mechanic-editor';
|
||||||
@@ -182,7 +185,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function eventPredicateOperatorOptions(clause: ConditionalMechanicClauseDraft) {
|
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
|
||||||
: predicateOperatorOptions.filter((option) => option.value === 'eq' || option.value === 'ne');
|
: predicateOperatorOptions.filter((option) => option.value === 'eq' || option.value === 'ne');
|
||||||
}
|
}
|
||||||
@@ -190,15 +195,21 @@
|
|||||||
function setClauseEvent(clauseIndex: number, event: string) {
|
function setClauseEvent(clauseIndex: number, event: string) {
|
||||||
const nextEvent = event as ConditionalMechanicClauseDraft['event'];
|
const nextEvent = event as ConditionalMechanicClauseDraft['event'];
|
||||||
const clause = rule.clauses[clauseIndex];
|
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 fields = registeredEventFields(nextEvent);
|
||||||
const comparison = readEventFieldComparison(clause.if);
|
const comparison = readEventFieldComparison(clause.if);
|
||||||
if (!comparison || !fields.length) {
|
if (!comparison || !fields.length) {
|
||||||
updateClause(clauseIndex, { event: nextEvent });
|
updateClause(clauseIndex, { event: nextEvent, outcomes });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const field = fields[0];
|
const field = fields[0];
|
||||||
updateClause(clauseIndex, {
|
updateClause(clauseIndex, {
|
||||||
event: nextEvent,
|
event: nextEvent,
|
||||||
|
outcomes,
|
||||||
if: eventFieldComparisonPredicate({
|
if: eventFieldComparisonPredicate({
|
||||||
path: field.path,
|
path: field.path,
|
||||||
operator: 'eq',
|
operator: 'eq',
|
||||||
@@ -403,6 +414,69 @@
|
|||||||
updateClause(clauseIndex, { outcomes });
|
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) {
|
function updateVariant(clauseIndex: number, outcomeIndex: number, variant: string, raw: string) {
|
||||||
const outcome = rule.clauses[clauseIndex].outcomes[outcomeIndex];
|
const outcome = rule.clauses[clauseIndex].outcomes[outcomeIndex];
|
||||||
const value = Number(raw);
|
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) {
|
function updateMutation(clauseIndex: number, mutationIndex: number, kind: 'add' | 'set' | 'consume' | 'clear', pool?: string, value?: number) {
|
||||||
const mutations = [...rule.clauses[clauseIndex].mutations];
|
const mutations = [...rule.clauses[clauseIndex].mutations];
|
||||||
const current = mutations[mutationIndex];
|
const current = mutations[mutationIndex];
|
||||||
@@ -981,6 +1099,20 @@
|
|||||||
{:else if conditionModel(clause) === 'advanced'}
|
{: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>
|
<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}
|
||||||
|
{#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>
|
||||||
<div class="reducer-controls">
|
<div class="reducer-controls">
|
||||||
<SegmentedChoice
|
<SegmentedChoice
|
||||||
@@ -1007,38 +1139,53 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mutation-panel">
|
<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}
|
{#each clause.mutations as mutation, mutationIndex}
|
||||||
<div class="mutation-row">
|
{#if mutation.kind === 'extend_duration'}
|
||||||
<SegmentedChoice
|
<div class="mutation-row ability-mutation-row">
|
||||||
legend="OPERATION"
|
<div class="branch-readout"><span>OPERATION</span><strong>EXTEND ASSIGNED ABILITY</strong></div>
|
||||||
name={`${idPrefix}-mutation-kind-${clauseIndex}-${mutationIndex}`}
|
<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>
|
||||||
options={[{value:'add',label:'ADD'},{value:'set',label:'SET'},{value:'consume',label:'CONSUME'},{value:'clear',label:'CLEAR'}]}
|
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{mutations:clause.mutations.filter((_,index) => index !== mutationIndex)})}>REMOVE</button>
|
||||||
value={mutation.kind === 'none' || mutation.kind === 'derive' || mutation.kind === 'transfer' ? 'add' : mutation.kind}
|
</div>
|
||||||
onchange={(values) => updateMutation(clauseIndex,mutationIndex,values[0] as 'add'|'set'|'consume'|'clear')}
|
{:else if mutation.kind === 'start_cooldown_on_max'}
|
||||||
/>
|
<div class="mutation-row cooldown-mutation-row">
|
||||||
<SegmentedChoice
|
<div class="branch-readout"><span>OPERATION</span><strong>START COOLDOWN ON MAX</strong></div>
|
||||||
legend="STATE POOL"
|
<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]})}/>
|
||||||
name={`${idPrefix}-mutation-pool-${clauseIndex}-${mutationIndex}`}
|
<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]})}/>
|
||||||
options={rule.pools.map((pool) => ({value:pool.key,label:pool.label.toUpperCase()}))}
|
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{mutations:clause.mutations.filter((_,index) => index !== mutationIndex)})}>REMOVE</button>
|
||||||
value={'pool' in mutation ? mutation.pool : rule.pools[0]?.key ?? ''}
|
</div>
|
||||||
columns={Math.min(4,Math.max(1,rule.pools.length))}
|
{:else}
|
||||||
onchange={(values) => updateMutation(clauseIndex,mutationIndex,mutation.kind === 'clear' ? 'clear' : mutation.kind === 'set' || mutation.kind === 'consume' ? mutation.kind : 'add',values[0])}
|
<div class="mutation-row">
|
||||||
/>
|
|
||||||
{#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
|
<SegmentedChoice
|
||||||
legend="STATE OWNER"
|
legend="OPERATION"
|
||||||
name={`${idPrefix}-mutation-entity-${clauseIndex}-${mutationIndex}`}
|
name={`${idPrefix}-mutation-kind-${clauseIndex}-${mutationIndex}`}
|
||||||
options={[{value:'event_target',label:'EVENT TARGET'},{value:'event_actor',label:'EVENT ACTOR'}]}
|
options={[{value:'add',label:'ADD'},{value:'set',label:'SET'},{value:'consume',label:'CONSUME'},{value:'clear',label:'CLEAR'}]}
|
||||||
value={'entity' in mutation && mutation.entity?.kind === 'event_actor' ? 'event_actor' : 'event_target'}
|
value={mutation.kind === 'none' || mutation.kind === 'derive' || mutation.kind === 'transfer' ? 'add' : mutation.kind}
|
||||||
onchange={(values) => updateMutationEntity(clauseIndex,mutationIndex,values[0] as 'event_target'|'event_actor')}
|
onchange={(values) => updateMutation(clauseIndex,mutationIndex,values[0] as 'add'|'set'|'consume'|'clear')}
|
||||||
/>
|
/>
|
||||||
{/if}
|
<SegmentedChoice
|
||||||
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{mutations:clause.mutations.filter((_,index) => index !== mutationIndex)})}>REMOVE</button>
|
legend="STATE POOL"
|
||||||
</div>
|
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}
|
{:else}
|
||||||
<p class="empty-state">This branch emits outcomes without changing shared state.</p>
|
<p class="empty-state">This branch emits outcomes without changing shared state.</p>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -1049,25 +1196,24 @@
|
|||||||
<div class="outcome-card">
|
<div class="outcome-card">
|
||||||
<div class="outcome-grid">
|
<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>
|
<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>
|
<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>
|
</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'}
|
{#if outcome.scope === 'allies' || outcome.scope === 'self_and_allies' || outcome.scope === 'target'}
|
||||||
<div class="delivery-controls">
|
<div class="delivery-controls">
|
||||||
<SegmentedChoice
|
<SegmentedChoice
|
||||||
legend="AREA TARGETING"
|
legend="AREA TARGETING"
|
||||||
name={`${idPrefix}-targeting-${clauseIndex}-${outcomeIndex}`}
|
name={`${idPrefix}-targeting-${clauseIndex}-${outcomeIndex}`}
|
||||||
options={[{value:'none',label:'NONE'},{value:'allies',label:'ALLIES IN RADIUS'},{value:'enemies',label:'ENEMIES IN RADIUS'}]}
|
options={targetingOptions(clause,outcome)}
|
||||||
value={outcome.targeting?.population ?? 'none'}
|
value={mechanicTargetingMode(outcome.targeting)}
|
||||||
onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{
|
onchange={(values) => setOutcomeTargeting(clauseIndex,outcomeIndex,values[0] as MechanicTargetingMode)}
|
||||||
targeting: values[0] === 'none'
|
|
||||||
? undefined
|
|
||||||
: {kind:'within_radius',population:values[0] as 'allies'|'enemies',radiusMeters:outcome.targeting?.radiusMeters ?? 20}
|
|
||||||
})}
|
|
||||||
/>
|
/>
|
||||||
{#if outcome.targeting}
|
{#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>
|
<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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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>
|
<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}
|
{/each}
|
||||||
</div>
|
</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">
|
<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>
|
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{outcomes:clause.outcomes.filter((_,index) => index !== outcomeIndex)})}>REMOVE OUTCOME</button>
|
||||||
</div>
|
</div>
|
||||||
</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>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)}
|
.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)}
|
.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{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}
|
.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)}
|
.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)}
|
.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)}
|
.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-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)}
|
.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-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}
|
.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}
|
.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}
|
.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}
|
.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}
|
.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,.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)}
|
.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 DatabaseBadge = { label: string; tone?: string };
|
||||||
export type DatabaseMeter = { label: string; value: number };
|
export type DatabaseMeter = { label: string; value: number };
|
||||||
|
export type DatabaseWeaponModSlot = {
|
||||||
|
slot: string;
|
||||||
|
unlockedAtTierOne: boolean;
|
||||||
|
iconUrl?: string;
|
||||||
|
};
|
||||||
export type DatabaseFact = {
|
export type DatabaseFact = {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
@@ -26,8 +31,10 @@ export type DatabaseItem = {
|
|||||||
renderUrl?: string;
|
renderUrl?: string;
|
||||||
gameIcon?: GameIconName;
|
gameIcon?: GameIconName;
|
||||||
headerBadge?: string;
|
headerBadge?: string;
|
||||||
|
headerBadgeTone?: string;
|
||||||
headerBadges?: string[];
|
headerBadges?: string[];
|
||||||
headerMetaIcon?: GameIconName;
|
headerMetaIcon?: GameIconName;
|
||||||
|
topBorderTone?: string;
|
||||||
badges?: DatabaseBadge[];
|
badges?: DatabaseBadge[];
|
||||||
facts: DatabaseFact[];
|
facts: DatabaseFact[];
|
||||||
factLayout?: 'grid' | 'stack';
|
factLayout?: 'grid' | 'stack';
|
||||||
@@ -35,9 +42,17 @@ export type DatabaseItem = {
|
|||||||
lines?: DatabaseFact[];
|
lines?: DatabaseFact[];
|
||||||
afterLines?: DatabaseFact[];
|
afterLines?: DatabaseFact[];
|
||||||
iconGroups?: DatabaseIconGroup[];
|
iconGroups?: DatabaseIconGroup[];
|
||||||
|
weaponModSlots?: DatabaseWeaponModSlot[];
|
||||||
description?: string;
|
description?: string;
|
||||||
secondaryDescription?: string;
|
secondaryDescription?: string;
|
||||||
descriptionStyle?: 'prose' | 'data';
|
descriptionStyle?: 'prose' | 'data';
|
||||||
|
protocolPreview?: {
|
||||||
|
primaryStat: string;
|
||||||
|
primaryUnit: string;
|
||||||
|
primaryBaseValue: number;
|
||||||
|
talentDescription?: string;
|
||||||
|
talentEffects: Array<{ baseValue: number; unit: string }>;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DatabaseRelatedItem = {
|
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(input.canonicalCatalog.grammar.outcome.optional).toEqual(
|
||||||
expect.arrayContaining(['delivery', 'durationSeconds']),
|
expect.arrayContaining(['delivery', 'durationSeconds']),
|
||||||
);
|
);
|
||||||
@@ -136,7 +136,7 @@ describe('AI mechanic authoring prompt', () => {
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
|
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
|
||||||
'Prompt contract version: 7',
|
'Prompt contract version: 10',
|
||||||
);
|
);
|
||||||
|
|
||||||
const advertised = structuredClone(
|
const advertised = structuredClone(
|
||||||
@@ -185,7 +185,7 @@ describe('AI mechanic authoring prompt', () => {
|
|||||||
expect(parsed.ok).toBe(true);
|
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(
|
const input = JSON.parse(
|
||||||
buildAiMechanicAuthoringInput({
|
buildAiMechanicAuthoringInput({
|
||||||
request,
|
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(
|
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
|
||||||
'Prompt contract version: 7',
|
'Prompt contract version: 10',
|
||||||
);
|
);
|
||||||
expect(input.canonicalCatalog.events).toContainEqual({
|
expect(input.canonicalCatalog.events).toContainEqual({
|
||||||
key: 'extra_health_changed',
|
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', () => {
|
it('advertises authoritative current Health percentage with strict band semantics', () => {
|
||||||
const input = JSON.parse(
|
const input = JSON.parse(
|
||||||
buildAiMechanicAuthoringInput({
|
buildAiMechanicAuthoringInput({
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES } from '$lib/calculations/mec
|
|||||||
import { MECHANIC_COMBAT_REFERENCE_REGISTRY } from '$lib/calculations/mechanic';
|
import { MECHANIC_COMBAT_REFERENCE_REGISTRY } from '$lib/calculations/mechanic';
|
||||||
import type { AiMechanicAttribute } from './mechanic-authoring-context';
|
import type { AiMechanicAttribute } from './mechanic-authoring-context';
|
||||||
|
|
||||||
export const AI_MECHANIC_PROMPT_VERSION = 7;
|
export const AI_MECHANIC_PROMPT_VERSION = 10;
|
||||||
|
|
||||||
const mechanicsGrammar = {
|
const mechanicsGrammar = {
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -40,7 +40,14 @@ const mechanicsGrammar = {
|
|||||||
},
|
},
|
||||||
clause: {
|
clause: {
|
||||||
required: ['key', 'event', 'if', 'reducer', 'mutations', 'outcomes'],
|
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'],
|
predicateKinds: ['true', 'exists', 'compare', 'all', 'any', 'not'],
|
||||||
compareOperators: ['eq', 'ne', 'gt', 'gte', 'lt', 'lte'],
|
compareOperators: ['eq', 'ne', 'gt', 'gte', 'lt', 'lte'],
|
||||||
@@ -63,6 +70,8 @@ const mechanicsGrammar = {
|
|||||||
'derive',
|
'derive',
|
||||||
'clear',
|
'clear',
|
||||||
'transfer',
|
'transfer',
|
||||||
|
'extend_duration',
|
||||||
|
'start_cooldown_on_max',
|
||||||
],
|
],
|
||||||
entitySelectors: ['agent', 'event_actor', 'event_target', 'event_field'],
|
entitySelectors: ['agent', 'event_actor', 'event_target', 'event_field'],
|
||||||
valueReferences: [
|
valueReferences: [
|
||||||
@@ -70,6 +79,7 @@ const mechanicsGrammar = {
|
|||||||
'scenario',
|
'scenario',
|
||||||
'combat',
|
'combat',
|
||||||
'equipment',
|
'equipment',
|
||||||
|
'assigned_ability',
|
||||||
'attribute',
|
'attribute',
|
||||||
'reducer',
|
'reducer',
|
||||||
'pool',
|
'pool',
|
||||||
@@ -97,6 +107,7 @@ const mechanicsGrammar = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ ref: { source: 'equipment', path: 'protocolTalentUpgrades' } },
|
{ ref: { source: 'equipment', path: 'protocolTalentUpgrades' } },
|
||||||
|
{ ref: { source: 'assigned_ability', path: 'remainingDurationSeconds' } },
|
||||||
{
|
{
|
||||||
ref: {
|
ref: {
|
||||||
source: 'attribute',
|
source: 'attribute',
|
||||||
@@ -200,6 +211,16 @@ const mechanicsGrammar = {
|
|||||||
value: 'all',
|
value: 'all',
|
||||||
clearSource: true,
|
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,
|
outcome: CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES,
|
||||||
continuousRestorationOutcomeShape: {
|
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: {
|
preciseTraumaShape: {
|
||||||
version: 1,
|
version: 1,
|
||||||
simulationReadiness: 'ready',
|
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.',
|
'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.',
|
'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.',
|
'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.',
|
'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.',
|
'OS Protocol upgrade scaling may use equipment.protocolTalentUpgrades only when allowProtocolUpgradeScaling is true.',
|
||||||
'Assigned-ability scope may be used only when allowAssignedAbility 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 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.',
|
'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.',
|
'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());
|
beforeEach(() => resetAiMechanicRateLimitForTests());
|
||||||
|
|
||||||
it('allows only one in-flight request per account', () => {
|
it('allows only one in-flight request per account', () => {
|
||||||
const release = reserveAiMechanicGeneration('agent-1', 1_000);
|
const release = reserveAiMechanicGeneration('agent-1', false, 1_000);
|
||||||
expect(() => reserveAiMechanicGeneration('agent-1', 1_001)).toThrow(
|
expect(() => reserveAiMechanicGeneration('agent-1', false, 1_001)).toThrow(
|
||||||
AiMechanicRateLimitError,
|
AiMechanicRateLimitError,
|
||||||
);
|
);
|
||||||
release();
|
release();
|
||||||
expect(() => reserveAiMechanicGeneration('agent-1', 1_002)).not.toThrow();
|
expect(() =>
|
||||||
|
reserveAiMechanicGeneration('agent-1', false, 1_002),
|
||||||
|
).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('enforces the hourly account limit', () => {
|
it('enforces the hourly account limit', () => {
|
||||||
for (let request = 0; request < 10; request += 1) {
|
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();
|
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.',
|
'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
|
* duplicate submissions and basic abuse; OpenAI project budgets remain the hard
|
||||||
* cross-instance spending boundary.
|
* 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)) {
|
if (inFlightUsers.has(userId)) {
|
||||||
throw new AiMechanicRateLimitError(
|
throw new AiMechanicRateLimitError(
|
||||||
'A mechanic authoring request is already running for this account.',
|
'A mechanic authoring request is already running for this account.',
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export async function syncArticleTags(database: Database) {
|
|||||||
`specialization-${item.id}`,
|
`specialization-${item.id}`,
|
||||||
'specialization',
|
'specialization',
|
||||||
String(item.name),
|
String(item.name),
|
||||||
`specialization-${slugify(String(item.name))}`,
|
`specialization-${slugify(String(item.name))}-${item.id}`,
|
||||||
'specialization',
|
'specialization',
|
||||||
item.id,
|
item.id,
|
||||||
Number(item.active) ? 1 : 0,
|
Number(item.active) ? 1 : 0,
|
||||||
@@ -93,7 +93,7 @@ export async function syncArticleTags(database: Database) {
|
|||||||
`focus-${item.id}`,
|
`focus-${item.id}`,
|
||||||
'focus',
|
'focus',
|
||||||
name,
|
name,
|
||||||
`focus-${slugify(name)}`,
|
`focus-${slugify(name)}-${item.id}`,
|
||||||
'focus',
|
'focus',
|
||||||
item.id,
|
item.id,
|
||||||
Number(item.active) ? 1 : 0,
|
Number(item.active) ? 1 : 0,
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export async function recordIsacAudit(input: {
|
|||||||
export async function auditRecord(
|
export async function auditRecord(
|
||||||
table: string,
|
table: string,
|
||||||
id: string,
|
id: string,
|
||||||
|
suppliedDatabase?: Database,
|
||||||
): Promise<Record<string, unknown> | null> {
|
): Promise<Record<string, unknown> | null> {
|
||||||
if (
|
if (
|
||||||
![
|
![
|
||||||
@@ -56,7 +57,7 @@ export async function auditRecord(
|
|||||||
) {
|
) {
|
||||||
throw new Error('Unsupported audit table');
|
throw new Error('Unsupported audit table');
|
||||||
}
|
}
|
||||||
const database = await getDatabase();
|
const database = suppliedDatabase ?? (await getDatabase());
|
||||||
return (
|
return (
|
||||||
(await (
|
(await (
|
||||||
await database.prepare(`SELECT * FROM ${table} WHERE id=?`)
|
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 type { Database } from '@tursodatabase/database';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomBytes } from 'node:crypto';
|
||||||
import { deleteUploadedAvatar } from '$lib/server/avatars';
|
import { unlink } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { avatarStorageDirectory } from '$lib/server/avatars';
|
||||||
import { displayNameKey } from '$lib/agents';
|
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(
|
export async function softDeleteAccount(
|
||||||
database: Database,
|
database: Database,
|
||||||
@@ -10,113 +27,189 @@ export async function softDeleteAccount(
|
|||||||
reason: string | null,
|
reason: string | null,
|
||||||
purgeSecurityData = false,
|
purgeSecurityData = false,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const user = await (
|
const deletion = database.transactionAsync(async (transaction) => {
|
||||||
await database.prepare(`
|
const user = await (
|
||||||
SELECT users.username,users.login_username,user_profiles.display_name,deleted_accounts.deleted_at
|
await transaction.prepare(`
|
||||||
FROM users LEFT JOIN user_profiles ON user_profiles.user_id=users.id
|
SELECT users.username,users.login_username,user_profiles.display_name,
|
||||||
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
|
user_status.blocked_at,user_status.banned_until,deleted_accounts.deleted_at
|
||||||
WHERE users.id=?
|
FROM users LEFT JOIN user_profiles ON user_profiles.user_id=users.id
|
||||||
`)
|
LEFT JOIN user_status ON user_status.user_id=users.id
|
||||||
).get(userId);
|
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
|
||||||
if (!user || user.deleted_at) return false;
|
WHERE users.id=?
|
||||||
const now = new Date().toISOString();
|
`)
|
||||||
const deletedUsername = `deleted-agent-${userId.slice(0, 12)}`;
|
).get(userId);
|
||||||
const deletedDisplayName = `Deleted Agent ${userId.replaceAll('-', '').slice(0, 8)}`;
|
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 (
|
||||||
await database.prepare('DELETE FROM login_events WHERE user_id=?')
|
await transaction.prepare(
|
||||||
).run(userId);
|
'INSERT INTO deleted_accounts(user_id,original_username,deleted_at,deleted_by,reason) VALUES(?,?,?,?,?)',
|
||||||
}
|
)
|
||||||
await (
|
).run(
|
||||||
await database.prepare(
|
userId,
|
||||||
'INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,after_json,created_at) VALUES(?,?,?,?,?,?,?,?)',
|
purgeSecurityData
|
||||||
)
|
? deletedUsername
|
||||||
).run(
|
: String(user.login_username ?? user.username),
|
||||||
randomUUID(),
|
now,
|
||||||
deletedBy,
|
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',
|
|
||||||
reason,
|
reason,
|
||||||
securityDataPurged: purgeSecurityData,
|
);
|
||||||
}),
|
await (
|
||||||
now,
|
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;
|
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 { getDatabase } from '$lib/server/database';
|
||||||
import type { AuthUser } from './session';
|
import type { AuthUser } from './session';
|
||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
|
import {
|
||||||
|
hasActiveTotpFactor,
|
||||||
|
isFullAdministrator,
|
||||||
|
isMfaReady,
|
||||||
|
} from './bootstrap';
|
||||||
|
|
||||||
|
export const RECENT_AAL2_WINDOW_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
const REGISTERED_AGENT_PERMISSIONS = [
|
const REGISTERED_AGENT_PERMISSIONS = [
|
||||||
'isac.access',
|
'isac.access',
|
||||||
@@ -10,8 +17,14 @@ const REGISTERED_AGENT_PERMISSIONS = [
|
|||||||
|
|
||||||
export async function getPermissions(user: AuthUser | null): Promise<string[]> {
|
export async function getPermissions(user: AuthUser | null): Promise<string[]> {
|
||||||
if (!user) return [];
|
if (!user) return [];
|
||||||
if (user.role === 'admin') return ['*'];
|
|
||||||
const database = await getDatabase();
|
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 (
|
const rows = await (
|
||||||
await database.prepare(`
|
await database.prepare(`
|
||||||
SELECT permissions.name FROM permissions
|
SELECT permissions.name FROM permissions
|
||||||
@@ -27,10 +40,12 @@ export async function getPermissions(user: AuthUser | null): Promise<string[]> {
|
|||||||
const permissions = [
|
const permissions = [
|
||||||
...new Set([
|
...new Set([
|
||||||
...REGISTERED_AGENT_PERMISSIONS,
|
...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(
|
export async function hasPermission(
|
||||||
@@ -67,7 +82,60 @@ export async function requireDataView(
|
|||||||
return user;
|
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) 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';
|
import type { Database } from '@tursodatabase/database';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Atomically promotes the authenticated user when no administrator exists.
|
* Returns whether the account has the canonical unrestricted administrator
|
||||||
* The conditional UPDATE ensures concurrent first sign-ins cannot both win.
|
* 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,
|
database: Database,
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<void> {
|
): Promise<boolean> {
|
||||||
const promote = await database.prepare(`
|
const row = await (
|
||||||
UPDATE users
|
await database.prepare(`
|
||||||
SET role = 'admin'
|
SELECT 1
|
||||||
WHERE id = ?
|
FROM users
|
||||||
AND NOT EXISTS (
|
WHERE users.id=?
|
||||||
SELECT 1 FROM users WHERE role = 'admin'
|
AND EXISTS (
|
||||||
)
|
SELECT 1
|
||||||
`);
|
FROM user_roles
|
||||||
await promote.run(userId);
|
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 { dev } from '$app/environment';
|
||||||
import { getDatabase } from '$lib/server/database';
|
import { getDatabase } from '$lib/server/database';
|
||||||
|
import type { Database } from '@tursodatabase/database';
|
||||||
import type { Cookies } from '@sveltejs/kit';
|
import type { Cookies } from '@sveltejs/kit';
|
||||||
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
||||||
import { avatarUrl } from '$lib/avatar';
|
import { avatarUrl } from '$lib/avatar';
|
||||||
@@ -16,6 +17,27 @@ export type AuthUser = {
|
|||||||
role: string;
|
role: string;
|
||||||
discordLinked: boolean;
|
discordLinked: boolean;
|
||||||
avatarUrl: string;
|
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 = {
|
type LoginRequestContext = {
|
||||||
@@ -25,23 +47,43 @@ type LoginRequestContext = {
|
|||||||
credentialId?: string | null;
|
credentialId?: string | null;
|
||||||
sessionId?: string | null;
|
sessionId?: string | null;
|
||||||
riskFlags?: string[] | null;
|
riskFlags?: string[] | null;
|
||||||
|
aal?: SessionAuthLevel;
|
||||||
|
aal2VerifiedAt?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function hashToken(token: string): string {
|
function hashToken(token: string): string {
|
||||||
return createHash('sha256').update(token).digest('hex');
|
return createHash('sha256').update(token).digest('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createSession(
|
function sessionCookieOptions() {
|
||||||
cookies: Cookies,
|
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,
|
userId: string,
|
||||||
): Promise<string> {
|
options: CreateSessionOptions = {},
|
||||||
const database = await getDatabase();
|
): Promise<PendingSession> {
|
||||||
const token = randomBytes(32).toString('base64url');
|
const token = randomBytes(32).toString('base64url');
|
||||||
const sessionId = randomUUID();
|
const sessionId = randomUUID();
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const expiresAt = new Date(now.getTime() + SESSION_SECONDS * 1000);
|
const expiresAt = new Date(now.getTime() + SESSION_SECONDS * 1000);
|
||||||
const insert = await database.prepare(
|
const aal = options.authLevel ?? options.aal ?? 1;
|
||||||
'INSERT INTO sessions (id, user_id, token_hash, expires_at, created_at) VALUES (?, ?, ?, ?, ?)',
|
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(
|
await insert.run(
|
||||||
@@ -50,16 +92,67 @@ export async function createSession(
|
|||||||
hashToken(token),
|
hashToken(token),
|
||||||
expiresAt.toISOString(),
|
expiresAt.toISOString(),
|
||||||
now.toISOString(),
|
now.toISOString(),
|
||||||
|
aal,
|
||||||
|
aalVerifiedAt,
|
||||||
|
aal2VerifiedAt,
|
||||||
);
|
);
|
||||||
|
|
||||||
cookies.set(COOKIE_NAME, token, {
|
return { sessionId, token };
|
||||||
path: '/',
|
}
|
||||||
httpOnly: true,
|
|
||||||
sameSite: 'lax',
|
export async function createSession(
|
||||||
secure: !dev,
|
cookies: Cookies,
|
||||||
maxAge: SESSION_SECONDS,
|
userId: string,
|
||||||
});
|
options: CreateSessionOptions = {},
|
||||||
return sessionId;
|
): 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) {
|
function describeClient(userAgent: string) {
|
||||||
@@ -110,6 +203,23 @@ export async function recordLogin(
|
|||||||
context: LoginRequestContext = {},
|
context: LoginRequestContext = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const database = await getDatabase();
|
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 =
|
const userAgent =
|
||||||
context.request?.headers.get('user-agent')?.slice(0, 1000) ?? null;
|
context.request?.headers.get('user-agent')?.slice(0, 1000) ?? null;
|
||||||
const headers = context.request?.headers;
|
const headers = context.request?.headers;
|
||||||
@@ -134,10 +244,10 @@ export async function recordLogin(
|
|||||||
/* unavailable in some development adapters */
|
/* unavailable in some development adapters */
|
||||||
}
|
}
|
||||||
await (
|
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,
|
client_name,operating_system,device_type,location_country,location_region,location_city,
|
||||||
failure_reason,risk_flags,credential_id,session_id,created_at)
|
failure_reason,risk_flags,credential_id,session_id,aal,aal2_verified_at,created_at)
|
||||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
||||||
).run(
|
).run(
|
||||||
randomUUID(),
|
randomUUID(),
|
||||||
userId,
|
userId,
|
||||||
@@ -155,9 +265,10 @@ export async function recordLogin(
|
|||||||
riskFlags.length ? riskFlags.join(',') : null,
|
riskFlags.length ? riskFlags.join(',') : null,
|
||||||
context.credentialId ?? null,
|
context.credentialId ?? null,
|
||||||
context.sessionId ?? null,
|
context.sessionId ?? null,
|
||||||
|
context.aal ?? (succeeded ? 1 : null),
|
||||||
|
context.aal2VerifiedAt ?? null,
|
||||||
new Date().toISOString(),
|
new Date().toISOString(),
|
||||||
);
|
);
|
||||||
await purgeExpiredLoginMetadata();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSessionUser(
|
export async function getSessionUser(
|
||||||
@@ -173,7 +284,9 @@ export async function getSessionUser(
|
|||||||
users.role, users.discord_id, user_avatars.source AS avatar_source,
|
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_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,
|
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
|
FROM sessions
|
||||||
JOIN users ON users.id = sessions.user_id
|
JOIN users ON users.id = sessions.user_id
|
||||||
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
|
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
|
||||||
@@ -224,6 +337,12 @@ export async function getSessionUser(
|
|||||||
String(user.id),
|
String(user.id),
|
||||||
user.avatar_updated_at,
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -187,6 +187,7 @@ describe('conditional mechanic persistence adapter', () => {
|
|||||||
value_scaling TEXT, unit TEXT, damage_basis TEXT, application_phase TEXT,
|
value_scaling TEXT, unit TEXT, damage_basis TEXT, application_phase TEXT,
|
||||||
combat_timing TEXT, resource_kind TEXT, delivery_mode 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,
|
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
|
description_slot INTEGER, sort_order INTEGER, created_at TEXT, updated_at TEXT
|
||||||
)`);
|
)`);
|
||||||
|
|
||||||
@@ -220,6 +221,8 @@ describe('conditional mechanic persistence adapter', () => {
|
|||||||
key: 'weapon-kill',
|
key: 'weapon-kill',
|
||||||
label: 'Weapon kill',
|
label: 'Weapon kill',
|
||||||
event: 'enemy_killed',
|
event: 'enemy_killed',
|
||||||
|
procChancePercent: 29,
|
||||||
|
requiresAssignedAbilityActive: true,
|
||||||
if: {
|
if: {
|
||||||
kind: 'compare',
|
kind: 'compare',
|
||||||
left: {
|
left: {
|
||||||
@@ -255,6 +258,7 @@ describe('conditional mechanic persistence adapter', () => {
|
|||||||
population: 'allies',
|
population: 'allies',
|
||||||
radiusMeters: 40,
|
radiusMeters: 40,
|
||||||
},
|
},
|
||||||
|
probabilityPercent: 29,
|
||||||
variants: {
|
variants: {
|
||||||
I: { value: 240 },
|
I: { value: 240 },
|
||||||
II: { value: 336 },
|
II: { value: 336 },
|
||||||
@@ -263,12 +267,18 @@ describe('conditional mechanic persistence adapter', () => {
|
|||||||
{
|
{
|
||||||
key: 'target-detonation',
|
key: 'target-detonation',
|
||||||
label: 'Target Detonation',
|
label: 'Target Detonation',
|
||||||
phase: 'on_exit',
|
phase: 'after_event',
|
||||||
kind: 'direct_damage',
|
kind: 'direct_damage',
|
||||||
scope: 'target',
|
scope: 'target',
|
||||||
basis: 'flat',
|
basis: 'flat',
|
||||||
statePool: 'marked-target',
|
statePool: 'marked-target',
|
||||||
perStack: true,
|
perStack: true,
|
||||||
|
targeting: {
|
||||||
|
kind: 'event_target_area',
|
||||||
|
center: 'event_target',
|
||||||
|
validTargets: 'enemies',
|
||||||
|
radiusMeters: 5,
|
||||||
|
},
|
||||||
variants: {
|
variants: {
|
||||||
I: {
|
I: {
|
||||||
value: 240,
|
value: 240,
|
||||||
@@ -322,6 +332,23 @@ describe('conditional mechanic persistence adapter', () => {
|
|||||||
mutations: [],
|
mutations: [],
|
||||||
outcomes: [],
|
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',
|
population: 'allies',
|
||||||
radiusMeters: 40,
|
radiusMeters: 40,
|
||||||
},
|
},
|
||||||
|
probabilityPercent: 29,
|
||||||
variants: {
|
variants: {
|
||||||
I: { value: 240 },
|
I: { value: 240 },
|
||||||
II: { value: 336 },
|
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({
|
expect(loaded?.clauses[0].if).toEqual({
|
||||||
kind: 'compare',
|
kind: 'compare',
|
||||||
left: {
|
left: {
|
||||||
@@ -381,6 +420,10 @@ describe('conditional mechanic persistence adapter', () => {
|
|||||||
operator: 'lt',
|
operator: 'lt',
|
||||||
right: 75,
|
right: 75,
|
||||||
});
|
});
|
||||||
|
expect(loaded?.clauses[0]).toMatchObject({
|
||||||
|
procChancePercent: 29,
|
||||||
|
requiresAssignedAbilityActive: true,
|
||||||
|
});
|
||||||
expect(loaded?.pools[0]).toMatchObject({
|
expect(loaded?.pools[0]).toMatchObject({
|
||||||
key: 'marked-target',
|
key: 'marked-target',
|
||||||
stateKind: 'target_mark',
|
stateKind: 'target_mark',
|
||||||
@@ -399,7 +442,7 @@ describe('conditional mechanic persistence adapter', () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
expect(loaded?.clauses[0].outcomes[1]).toMatchObject({
|
expect(loaded?.clauses[0].outcomes[1]).toMatchObject({
|
||||||
phase: 'on_exit',
|
phase: 'after_event',
|
||||||
kind: 'direct_damage',
|
kind: 'direct_damage',
|
||||||
scope: 'target',
|
scope: 'target',
|
||||||
statePool: 'marked-target',
|
statePool: 'marked-target',
|
||||||
@@ -430,5 +473,14 @@ describe('conditional mechanic persistence adapter', () => {
|
|||||||
right: true,
|
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_mode',
|
||||||
'delivery_duration_seconds',
|
'delivery_duration_seconds',
|
||||||
'targeting_json',
|
'targeting_json',
|
||||||
|
'probability_percent',
|
||||||
'state_pool_id',
|
'state_pool_id',
|
||||||
'min_state_value',
|
'min_state_value',
|
||||||
'max_state_value',
|
'max_state_value',
|
||||||
@@ -205,6 +206,10 @@ export function conditionalRuleDraftFromRows(
|
|||||||
.filter((row) => row.rule_id === rule.id && Number(row.active ?? 1) === 1)
|
.filter((row) => row.rule_id === rule.id && Number(row.active ?? 1) === 1)
|
||||||
.sort((left, right) => Number(left.sort_order) - Number(right.sort_order))
|
.sort((left, right) => Number(left.sort_order) - Number(right.sort_order))
|
||||||
.map((row): ConditionalMechanicClauseDraft => {
|
.map((row): ConditionalMechanicClauseDraft => {
|
||||||
|
const parameters = parseJson<Record<string, unknown>>(
|
||||||
|
row.recipe_parameters_json,
|
||||||
|
{},
|
||||||
|
);
|
||||||
const grouped = new Map<string, OutcomeRow[]>();
|
const grouped = new Map<string, OutcomeRow[]>();
|
||||||
for (const outcome of rowsByClause.get(String(row.id)) ?? []) {
|
for (const outcome of rowsByClause.get(String(row.id)) ?? []) {
|
||||||
const key = String(outcome.effect_key);
|
const key = String(outcome.effect_key);
|
||||||
@@ -268,6 +273,10 @@ export function conditionalRuleDraftFromRows(
|
|||||||
...(first.targeting_json
|
...(first.targeting_json
|
||||||
? { targeting: parseJson(first.targeting_json, undefined) }
|
? { targeting: parseJson(first.targeting_json, undefined) }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(first.probability_percent === null ||
|
||||||
|
first.probability_percent === undefined
|
||||||
|
? {}
|
||||||
|
: { probabilityPercent: Number(first.probability_percent) }),
|
||||||
...(first.stat_attribute_id
|
...(first.stat_attribute_id
|
||||||
? { attributeId: String(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
|
...(row.cooldown_seconds === null || row.cooldown_seconds === undefined
|
||||||
? {}
|
? {}
|
||||||
: { cooldownSeconds: Number(row.cooldown_seconds) }),
|
: { cooldownSeconds: Number(row.cooldown_seconds) }),
|
||||||
@@ -495,7 +513,17 @@ export async function saveConditionalRuleInTransaction(
|
|||||||
clause.label || clause.key,
|
clause.label || clause.key,
|
||||||
clause.event,
|
clause.event,
|
||||||
clause.recipeKey ?? null,
|
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.if ?? { kind: 'true' }),
|
||||||
JSON.stringify(clause.reducer),
|
JSON.stringify(clause.reducer),
|
||||||
clause.weight === undefined ? null : JSON.stringify(clause.weight),
|
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,
|
application_kind,effect_scope,stat_attribute_id,value,value_expression_json,
|
||||||
value_basis,value_scaling,unit,damage_basis,application_phase,combat_timing,
|
value_basis,value_scaling,unit,damage_basis,application_phase,combat_timing,
|
||||||
resource_kind,delivery_mode,delivery_duration_seconds,
|
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)
|
created_at,updated_at)
|
||||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
||||||
).run(
|
).run(
|
||||||
randomUUID(),
|
randomUUID(),
|
||||||
ruleId,
|
ruleId,
|
||||||
@@ -552,6 +580,7 @@ export async function saveConditionalRuleInTransaction(
|
|||||||
outcome.targeting === undefined
|
outcome.targeting === undefined
|
||||||
? null
|
? null
|
||||||
: JSON.stringify(outcome.targeting),
|
: JSON.stringify(outcome.targeting),
|
||||||
|
outcome.probabilityPercent ?? null,
|
||||||
outcome.minStacks ?? null,
|
outcome.minStacks ?? null,
|
||||||
outcome.maxStacks ?? null,
|
outcome.maxStacks ?? null,
|
||||||
outcome.descriptionSlot ?? null,
|
outcome.descriptionSlot ?? null,
|
||||||
|
|||||||
@@ -37,14 +37,31 @@ const proposalResultKeys: Partial<
|
|||||||
saveAugmentProfile: { saved: 'augmentProfileSaved' },
|
saveAugmentProfile: { saved: 'augmentProfileSaved' },
|
||||||
},
|
},
|
||||||
weapons: { save: { saved: 'weaponSaved', mode: 'weaponMode' } },
|
weapons: { save: { saved: 'weaponSaved', mode: 'weaponMode' } },
|
||||||
weaponmods: { save: { saved: 'weaponModSaved', mode: 'weaponModMode' } },
|
weaponmods: {
|
||||||
gearmods: { save: { saved: 'gearModSaved', mode: 'gearModMode' } },
|
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' } },
|
talents: { saveTalent: { saved: 'talentSaved', mode: 'talentMode' } },
|
||||||
protocols: {
|
protocols: {
|
||||||
save: { saved: 'protocolSaved', mode: 'protocolMode' },
|
save: { saved: 'protocolSaved', mode: 'protocolMode' },
|
||||||
saveProfile: { saved: 'profileSaved' },
|
saveProfile: { saved: 'profileSaved' },
|
||||||
},
|
},
|
||||||
skillchips: { save: { saved: 'skillChipSaved', mode: 'skillChipMode' } },
|
skillchips: {
|
||||||
|
save: { saved: 'skillChipSaved', mode: 'skillChipMode' },
|
||||||
|
savePrimaryProfile: { saved: 'skillChipPrimaryProfileSaved' },
|
||||||
|
saveSecondaryPool: { saved: 'skillChipSecondaryPoolSaved' },
|
||||||
|
},
|
||||||
specializations: {
|
specializations: {
|
||||||
saveProgression: { saved: 'progressionSaved' },
|
saveProgression: { saved: 'progressionSaved' },
|
||||||
saveSpecialization: {
|
saveSpecialization: {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
BackgroundRemovalError,
|
BackgroundRemovalError,
|
||||||
removeBackgroundPreview,
|
removeBackgroundPreview,
|
||||||
} from './remove-background';
|
} from './remove-background';
|
||||||
|
import { weaponModSlotDefinitionById } from '$lib/weapons/mod-slots';
|
||||||
|
|
||||||
export const GAME_ICON_TYPES = [
|
export const GAME_ICON_TYPES = [
|
||||||
'talent',
|
'talent',
|
||||||
@@ -16,6 +17,7 @@ export const GAME_ICON_TYPES = [
|
|||||||
'specialization',
|
'specialization',
|
||||||
'specialization_ability',
|
'specialization_ability',
|
||||||
'specialization_node',
|
'specialization_node',
|
||||||
|
'weapon_mod_slot',
|
||||||
] as const;
|
] as const;
|
||||||
export type GameDataIconType = (typeof GAME_ICON_TYPES)[number];
|
export type GameDataIconType = (typeof GAME_ICON_TYPES)[number];
|
||||||
export const MAX_GAME_ICON_BYTES = 2 * 1024 * 1024;
|
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;
|
export const GAME_ICON_SIZE = 256;
|
||||||
const ENTITY_ID_PATTERN = /^[0-9a-f-]{36}$/i;
|
const ENTITY_ID_PATTERN = /^[0-9a-f-]{36}$/i;
|
||||||
const STORAGE_KEY_PATTERN =
|
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> = {
|
const LIBRARY_CATEGORY: Record<GameDataIconType, string> = {
|
||||||
talent: 'Talents',
|
talent: 'Talents',
|
||||||
brand_set: 'Brands',
|
brand_set: 'Brands',
|
||||||
@@ -32,6 +34,7 @@ const LIBRARY_CATEGORY: Record<GameDataIconType, string> = {
|
|||||||
specialization: 'Specializations',
|
specialization: 'Specializations',
|
||||||
specialization_ability: 'Specializations',
|
specialization_ability: 'Specializations',
|
||||||
specialization_node: 'Specializations',
|
specialization_node: 'Specializations',
|
||||||
|
weapon_mod_slot: 'Weapon Mods',
|
||||||
};
|
};
|
||||||
const LIBRARY_LABEL: Record<GameDataIconType, string> = {
|
const LIBRARY_LABEL: Record<GameDataIconType, string> = {
|
||||||
talent: 'Talent',
|
talent: 'Talent',
|
||||||
@@ -41,6 +44,7 @@ const LIBRARY_LABEL: Record<GameDataIconType, string> = {
|
|||||||
specialization: 'Specialization',
|
specialization: 'Specialization',
|
||||||
specialization_ability: 'Specialization Ability',
|
specialization_ability: 'Specialization Ability',
|
||||||
specialization_node: 'Specialization Tree Node',
|
specialization_node: 'Specialization Tree Node',
|
||||||
|
weapon_mod_slot: 'Weapon Mod Slot',
|
||||||
};
|
};
|
||||||
|
|
||||||
export class GameDataIconValidationError extends Error {}
|
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: [] };
|
return { name: null, tags: [] };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,24 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import sharp from 'sharp';
|
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) => ({
|
vi.mock('node:fs/promises', async (importOriginal) => ({
|
||||||
...(await importOriginal<typeof import('node:fs/promises')>()),
|
...(await importOriginal<typeof import('node:fs/promises')>()),
|
||||||
|
mkdir: mkdirMock,
|
||||||
rm: rmMock,
|
rm: rmMock,
|
||||||
|
writeFile: writeFileMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { GAME_ICON_TYPES } from './game-data-icons';
|
import { GAME_ICON_TYPES } from './game-data-icons';
|
||||||
import {
|
import {
|
||||||
GAME_DATA_RENDER_TYPES,
|
GAME_DATA_RENDER_TYPES,
|
||||||
ManagedImageDeletionError,
|
ManagedImageDeletionError,
|
||||||
|
applyGameDataRender,
|
||||||
deleteManagedImage,
|
deleteManagedImage,
|
||||||
processManagedImage,
|
processManagedImage,
|
||||||
} from './managed-images';
|
} from './managed-images';
|
||||||
@@ -26,6 +33,16 @@ class FakeDatabase {
|
|||||||
prepare(sql: string) {
|
prepare(sql: string) {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
get: async (...args: unknown[]) => {
|
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'))
|
if (sql.startsWith('SELECT * FROM managed_images'))
|
||||||
return this.images.get(String(args[0]));
|
return this.images.get(String(args[0]));
|
||||||
if (sql.startsWith('SELECT * FROM game_data_icons')) {
|
if (sql.startsWith('SELECT * FROM game_data_icons')) {
|
||||||
@@ -42,7 +59,64 @@ class FakeDatabase {
|
|||||||
}
|
}
|
||||||
if (sql.startsWith('DELETE FROM managed_images')) {
|
if (sql.startsWith('DELETE FROM managed_images')) {
|
||||||
const row = this.images.get(String(args[0]));
|
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;
|
return;
|
||||||
}
|
}
|
||||||
throw new Error(`Unexpected run: ${sql}`);
|
throw new Error(`Unexpected run: ${sql}`);
|
||||||
@@ -210,3 +284,94 @@ describe('processManagedImage', () => {
|
|||||||
expect(metadata.height).toBe(1000);
|
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 MAX_PIXELS = 24_000_000;
|
||||||
const ID_PATTERN = /^[0-9a-f-]{36}$/i;
|
const ID_PATTERN = /^[0-9a-f-]{36}$/i;
|
||||||
const KEY_PATTERN =
|
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 ManagedImageValidationError extends Error {}
|
||||||
export class ManagedImageDeletionError extends Error {}
|
export class ManagedImageDeletionError extends Error {}
|
||||||
@@ -250,7 +250,7 @@ export async function applyGameDataRender(
|
|||||||
);
|
);
|
||||||
await (
|
await (
|
||||||
await database.prepare(`UPDATE managed_images SET category=?,tags_json=?,is_icon=0,force_white=0,
|
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(
|
).run(
|
||||||
category[type],
|
category[type],
|
||||||
tags,
|
tags,
|
||||||
@@ -258,19 +258,14 @@ export async function applyGameDataRender(
|
|||||||
processed.output.length,
|
processed.output.length,
|
||||||
processed.width,
|
processed.width,
|
||||||
processed.height,
|
processed.height,
|
||||||
actorId,
|
|
||||||
now,
|
now,
|
||||||
existing.id,
|
existing.id,
|
||||||
);
|
);
|
||||||
return {
|
return (
|
||||||
...existing,
|
(await (
|
||||||
category: category[type],
|
await database.prepare('SELECT * FROM managed_images WHERE id=?')
|
||||||
tags_json: tags,
|
).get(existing.id)) ?? null
|
||||||
byte_size: processed.output.length,
|
);
|
||||||
width: processed.width,
|
|
||||||
height: processed.height,
|
|
||||||
updated_at: now,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
const storageKey = await saveManagedImageFile(id, processed.output);
|
const storageKey = await saveManagedImageFile(id, processed.output);
|
||||||
@@ -298,20 +293,11 @@ export async function applyGameDataRender(
|
|||||||
now,
|
now,
|
||||||
now,
|
now,
|
||||||
);
|
);
|
||||||
return {
|
return (
|
||||||
id,
|
(await (
|
||||||
name,
|
await database.prepare('SELECT * FROM managed_images WHERE id=?')
|
||||||
category: category[type],
|
).get(id)) ?? null
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteManagedImage(database: Database, id: string) {
|
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
|
||||||
|
>;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Database } from '@tursodatabase/database';
|
import type { Database } from '@tursodatabase/database';
|
||||||
import { attachWeaponCategoryBonuses } from '$lib/weapons/category-bonuses';
|
import { attachWeaponCategoryBonuses } from '$lib/weapons/category-bonuses';
|
||||||
|
import { hydrateSkillChipProfileRows } from '$lib/skill-chips/profiles';
|
||||||
|
|
||||||
export async function loadSharedBuildSummaryCatalog(database: Database) {
|
export async function loadSharedBuildSummaryCatalog(database: Database) {
|
||||||
const [
|
const [
|
||||||
@@ -15,7 +16,12 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
|
|||||||
talentEffects,
|
talentEffects,
|
||||||
talentTemplates,
|
talentTemplates,
|
||||||
chipSets,
|
chipSets,
|
||||||
|
abilitySkillChipLinks,
|
||||||
chipTwoPiece,
|
chipTwoPiece,
|
||||||
|
chipPrimaryOptions,
|
||||||
|
chipPrimaryQualityValues,
|
||||||
|
chipSecondaryOptions,
|
||||||
|
chipSecondaryQualityValues,
|
||||||
chipTalents,
|
chipTalents,
|
||||||
chipTalentEffects,
|
chipTalentEffects,
|
||||||
protocols,
|
protocols,
|
||||||
@@ -26,6 +32,8 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
|
|||||||
protocolTalentEffects,
|
protocolTalentEffects,
|
||||||
specializations,
|
specializations,
|
||||||
specializationFocuses,
|
specializationFocuses,
|
||||||
|
specializationAbilities,
|
||||||
|
focusAbilities,
|
||||||
specializationNodes,
|
specializationNodes,
|
||||||
specializationRanks,
|
specializationRanks,
|
||||||
specializationRankEffects,
|
specializationRankEffects,
|
||||||
@@ -80,9 +88,29 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
|
|||||||
'SELECT * FROM skill_chip_sets ORDER BY name COLLATE NOCASE',
|
'SELECT * FROM skill_chip_sets ORDER BY name COLLATE NOCASE',
|
||||||
)
|
)
|
||||||
).all(),
|
).all(),
|
||||||
|
(
|
||||||
|
await database.prepare(
|
||||||
|
`SELECT link.ability_id,link.compatible_ability_id
|
||||||
|
FROM specialization_ability_skill_chip_links link
|
||||||
|
JOIN specialization_abilities ability ON ability.id=link.ability_id AND ability.active=1
|
||||||
|
JOIN specialization_abilities compatible ON compatible.id=link.compatible_ability_id AND compatible.active=1`,
|
||||||
|
)
|
||||||
|
).all(),
|
||||||
(
|
(
|
||||||
await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')
|
await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')
|
||||||
).all(),
|
).all(),
|
||||||
|
(await database.prepare('SELECT * FROM skill_chip_primary_options')).all(),
|
||||||
|
(
|
||||||
|
await database.prepare('SELECT * FROM skill_chip_primary_quality_values')
|
||||||
|
).all(),
|
||||||
|
(
|
||||||
|
await database.prepare('SELECT * FROM skill_chip_secondary_options')
|
||||||
|
).all(),
|
||||||
|
(
|
||||||
|
await database.prepare(
|
||||||
|
'SELECT * FROM skill_chip_secondary_quality_values',
|
||||||
|
)
|
||||||
|
).all(),
|
||||||
(await database.prepare('SELECT * FROM skill_chip_talents')).all(),
|
(await database.prepare('SELECT * FROM skill_chip_talents')).all(),
|
||||||
(
|
(
|
||||||
await database.prepare(
|
await database.prepare(
|
||||||
@@ -119,6 +147,16 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
|
|||||||
'SELECT * FROM specialization_focuses ORDER BY specialization_id,display_order,name COLLATE NOCASE',
|
'SELECT * FROM specialization_focuses ORDER BY specialization_id,display_order,name COLLATE NOCASE',
|
||||||
)
|
)
|
||||||
).all(),
|
).all(),
|
||||||
|
(
|
||||||
|
await database.prepare(
|
||||||
|
'SELECT * FROM specialization_abilities ORDER BY specialization_id,name COLLATE NOCASE',
|
||||||
|
)
|
||||||
|
).all(),
|
||||||
|
(
|
||||||
|
await database.prepare(
|
||||||
|
'SELECT * FROM specialization_focus_abilities ORDER BY focus_id,ability_slot',
|
||||||
|
)
|
||||||
|
).all(),
|
||||||
(
|
(
|
||||||
await database.prepare(
|
await database.prepare(
|
||||||
'SELECT * FROM specialization_tree_nodes ORDER BY focus_id,row_number,column_number',
|
'SELECT * FROM specialization_tree_nodes ORDER BY focus_id,row_number,column_number',
|
||||||
@@ -161,12 +199,32 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
|
|||||||
),
|
),
|
||||||
})),
|
})),
|
||||||
})),
|
})),
|
||||||
|
skillChipProfile: {
|
||||||
|
primaryOptions: hydrateSkillChipProfileRows(
|
||||||
|
chipPrimaryOptions,
|
||||||
|
chipPrimaryQualityValues,
|
||||||
|
),
|
||||||
|
secondaryOptions: hydrateSkillChipProfileRows(
|
||||||
|
chipSecondaryOptions,
|
||||||
|
chipSecondaryQualityValues,
|
||||||
|
),
|
||||||
|
},
|
||||||
chipSets: chipSets.map((set) => {
|
chipSets: chipSets.map((set) => {
|
||||||
const talent = chipTalents.find(
|
const talent = chipTalents.find(
|
||||||
(item) => item.skill_chip_set_id === set.id,
|
(item) => item.skill_chip_set_id === set.id,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
...set,
|
...set,
|
||||||
|
compatibleAbilityIds: [
|
||||||
|
String(set.specialization_ability_id),
|
||||||
|
...abilitySkillChipLinks.flatMap((link) =>
|
||||||
|
link.ability_id === set.specialization_ability_id
|
||||||
|
? [String(link.compatible_ability_id)]
|
||||||
|
: link.compatible_ability_id === set.specialization_ability_id
|
||||||
|
? [String(link.ability_id)]
|
||||||
|
: [],
|
||||||
|
),
|
||||||
|
],
|
||||||
twoPiece:
|
twoPiece:
|
||||||
chipTwoPiece.find((item) => item.skill_chip_set_id === set.id) ??
|
chipTwoPiece.find((item) => item.skill_chip_set_id === set.id) ??
|
||||||
null,
|
null,
|
||||||
@@ -215,6 +273,18 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
|
|||||||
.filter((focus) => focus.specialization_id === specialization.id)
|
.filter((focus) => focus.specialization_id === specialization.id)
|
||||||
.map((focus) => ({
|
.map((focus) => ({
|
||||||
...focus,
|
...focus,
|
||||||
|
abilities: focusAbilities
|
||||||
|
.filter((link) => link.focus_id === focus.id)
|
||||||
|
.sort(
|
||||||
|
(left, right) =>
|
||||||
|
Number(left.ability_slot) - Number(right.ability_slot),
|
||||||
|
)
|
||||||
|
.map((link) =>
|
||||||
|
specializationAbilities.find(
|
||||||
|
(ability) => ability.id === link.ability_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.filter(Boolean),
|
||||||
nodes: specializationNodes
|
nodes: specializationNodes
|
||||||
.filter((node) => node.focus_id === focus.id)
|
.filter((node) => node.focus_id === focus.id)
|
||||||
.map((node) => ({
|
.map((node) => ({
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export async function loadSharedBuildProjection(
|
|||||||
).all(),
|
).all(),
|
||||||
(
|
(
|
||||||
await database.prepare(
|
await database.prepare(
|
||||||
'SELECT id,name,category,is_exotic,rpm,magazine,reload_speed,damage_type FROM weapons',
|
'SELECT id,name,category,is_exotic,rpm,magazine,reload_speed,damage_type,exotic_talent_id FROM weapons',
|
||||||
)
|
)
|
||||||
).all(),
|
).all(),
|
||||||
(await database.prepare('SELECT id,name FROM skill_chip_sets')).all(),
|
(await database.prepare('SELECT id,name FROM skill_chip_sets')).all(),
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import type { Database } from '@tursodatabase/database';
|
||||||
|
import {
|
||||||
|
SKILL_CHIP_PRIMARY_ATTRIBUTE_NAMES,
|
||||||
|
SKILL_CHIP_QUALITIES,
|
||||||
|
SKILL_CHIP_SECONDARY_QUALITIES,
|
||||||
|
type SkillChipProfile,
|
||||||
|
} from '$lib/skill-chips/profiles';
|
||||||
|
|
||||||
|
export async function getSkillChipPrimaryAttributeIds(database: Database) {
|
||||||
|
const rows = await (
|
||||||
|
await database.prepare(
|
||||||
|
`SELECT id,name FROM stat_attributes
|
||||||
|
WHERE active=1 AND name IN (${SKILL_CHIP_PRIMARY_ATTRIBUTE_NAMES.map(() => '?').join(',')})
|
||||||
|
ORDER BY name COLLATE NOCASE`,
|
||||||
|
)
|
||||||
|
).all(...SKILL_CHIP_PRIMARY_ATTRIBUTE_NAMES);
|
||||||
|
if (rows.length !== SKILL_CHIP_PRIMARY_ATTRIBUTE_NAMES.length)
|
||||||
|
throw new Error(
|
||||||
|
'All four fixed Skill Chip primary Attributes must be active.',
|
||||||
|
);
|
||||||
|
return rows.map((row) => String(row.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getProfiles(
|
||||||
|
database: Database,
|
||||||
|
optionTable: string,
|
||||||
|
valueTable: string,
|
||||||
|
) {
|
||||||
|
const [options, values] = await Promise.all([
|
||||||
|
(
|
||||||
|
await database.prepare(
|
||||||
|
`SELECT stat_attribute_id FROM ${optionTable} ORDER BY stat_attribute_id`,
|
||||||
|
)
|
||||||
|
).all(),
|
||||||
|
(
|
||||||
|
await database.prepare(
|
||||||
|
`SELECT * FROM ${valueTable} ORDER BY stat_attribute_id,quality`,
|
||||||
|
)
|
||||||
|
).all(),
|
||||||
|
]);
|
||||||
|
if (!options.length) return [];
|
||||||
|
return options.map((option) => ({
|
||||||
|
statId: String(option.stat_attribute_id),
|
||||||
|
values: Object.fromEntries(
|
||||||
|
values
|
||||||
|
.filter((value) => value.stat_attribute_id === option.stat_attribute_id)
|
||||||
|
.map((value) => [
|
||||||
|
String(value.quality),
|
||||||
|
{ min: Number(value.min_value), max: Number(value.max_value) },
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getSkillChipPrimaryProfiles = (database: Database) =>
|
||||||
|
getProfiles(
|
||||||
|
database,
|
||||||
|
'skill_chip_primary_options',
|
||||||
|
'skill_chip_primary_quality_values',
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getSkillChipSecondaryPool = (database: Database) =>
|
||||||
|
getProfiles(
|
||||||
|
database,
|
||||||
|
'skill_chip_secondary_options',
|
||||||
|
'skill_chip_secondary_quality_values',
|
||||||
|
);
|
||||||
|
|
||||||
|
async function replaceProfiles(
|
||||||
|
database: Database,
|
||||||
|
optionTable: string,
|
||||||
|
valueTable: string,
|
||||||
|
profiles: SkillChipProfile[],
|
||||||
|
qualities: readonly string[],
|
||||||
|
) {
|
||||||
|
await database.exec(`DELETE FROM ${valueTable}`);
|
||||||
|
await database.exec(`DELETE FROM ${optionTable}`);
|
||||||
|
for (const profile of profiles) {
|
||||||
|
await (
|
||||||
|
await database.prepare(
|
||||||
|
`INSERT INTO ${optionTable}(stat_attribute_id) VALUES(?)`,
|
||||||
|
)
|
||||||
|
).run(profile.statId);
|
||||||
|
for (const quality of qualities) {
|
||||||
|
const range = profile.values[quality];
|
||||||
|
await (
|
||||||
|
await database.prepare(
|
||||||
|
`INSERT INTO ${valueTable}(stat_attribute_id,quality,min_value,max_value) VALUES(?,?,?,?)`,
|
||||||
|
)
|
||||||
|
).run(profile.statId, quality, range.min, range.max);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const replaceSkillChipPrimaryProfiles = (
|
||||||
|
database: Database,
|
||||||
|
profiles: SkillChipProfile[],
|
||||||
|
) =>
|
||||||
|
replaceProfiles(
|
||||||
|
database,
|
||||||
|
'skill_chip_primary_options',
|
||||||
|
'skill_chip_primary_quality_values',
|
||||||
|
profiles,
|
||||||
|
SKILL_CHIP_QUALITIES,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const replaceSkillChipSecondaryPool = (
|
||||||
|
database: Database,
|
||||||
|
profiles: SkillChipProfile[],
|
||||||
|
) =>
|
||||||
|
replaceProfiles(
|
||||||
|
database,
|
||||||
|
'skill_chip_secondary_options',
|
||||||
|
'skill_chip_secondary_quality_values',
|
||||||
|
profiles,
|
||||||
|
SKILL_CHIP_SECONDARY_QUALITIES,
|
||||||
|
);
|
||||||
@@ -17,37 +17,51 @@ export async function refreshSpecializationFocusReady(
|
|||||||
abilityCount: 0,
|
abilityCount: 0,
|
||||||
chipReadyCount: 0,
|
chipReadyCount: 0,
|
||||||
};
|
};
|
||||||
const [config, abilityCountRow, chipReadyRow, nodes, rankProfiles] =
|
const [
|
||||||
await Promise.all([
|
config,
|
||||||
(
|
abilityCountRow,
|
||||||
await database.prepare(
|
profileRow,
|
||||||
"SELECT xp_points,shd_cache_points FROM specialization_progression_config WHERE id='default'",
|
chipReadyRow,
|
||||||
)
|
nodes,
|
||||||
).get(),
|
rankProfiles,
|
||||||
(
|
] = await Promise.all([
|
||||||
await database.prepare(`SELECT COUNT(*) AS count FROM specialization_focus_abilities link
|
(
|
||||||
|
await database.prepare(
|
||||||
|
"SELECT xp_points,shd_cache_points FROM specialization_progression_config WHERE id='default'",
|
||||||
|
)
|
||||||
|
).get(),
|
||||||
|
(
|
||||||
|
await database.prepare(`SELECT COUNT(*) AS count FROM specialization_focus_abilities link
|
||||||
JOIN specialization_abilities ability ON ability.id=link.ability_id
|
JOIN specialization_abilities ability ON ability.id=link.ability_id
|
||||||
WHERE link.focus_id=? AND ability.active=1`)
|
WHERE link.focus_id=? AND ability.active=1`)
|
||||||
).get(focusId),
|
).get(focusId),
|
||||||
(
|
(
|
||||||
await database.prepare(`SELECT COUNT(*) AS count FROM specialization_focus_abilities link
|
await database.prepare(`SELECT
|
||||||
|
(SELECT COUNT(*) FROM skill_chip_primary_options)=4
|
||||||
|
AND (SELECT COUNT(*) FROM skill_chip_primary_quality_values)=16
|
||||||
|
AND (SELECT COUNT(*) FROM skill_chip_secondary_options)>=2
|
||||||
|
AND (SELECT COUNT(*) FROM skill_chip_secondary_quality_values)=
|
||||||
|
(SELECT COUNT(*)*3 FROM skill_chip_secondary_options) AS ready`)
|
||||||
|
).get(),
|
||||||
|
(
|
||||||
|
await database.prepare(`SELECT COUNT(*) AS count FROM specialization_focus_abilities link
|
||||||
JOIN specialization_abilities ability ON ability.id=link.ability_id AND ability.active=1
|
JOIN specialization_abilities ability ON ability.id=link.ability_id AND ability.active=1
|
||||||
WHERE link.focus_id=? AND (
|
WHERE link.focus_id=? AND (
|
||||||
SELECT COUNT(*) FROM skill_chip_sets chips
|
SELECT COUNT(*) FROM skill_chip_sets chips
|
||||||
WHERE chips.specialization_ability_id=ability.id AND chips.active=1
|
WHERE chips.specialization_ability_id=ability.id AND chips.active=1
|
||||||
)=3`)
|
)=3`)
|
||||||
).get(focusId),
|
).get(focusId),
|
||||||
(
|
(
|
||||||
await database.prepare(
|
await database.prepare(
|
||||||
'SELECT row_number,max_rank FROM specialization_tree_nodes WHERE focus_id=? AND active=1 ORDER BY row_number,column_number',
|
'SELECT row_number,max_rank FROM specialization_tree_nodes WHERE focus_id=? AND active=1 ORDER BY row_number,column_number',
|
||||||
)
|
)
|
||||||
).all(focusId),
|
).all(focusId),
|
||||||
(
|
(
|
||||||
await database.prepare(
|
await database.prepare(
|
||||||
'SELECT rank,point_cost FROM specialization_rank_cost_profiles ORDER BY rank',
|
'SELECT rank,point_cost FROM specialization_rank_cost_profiles ORDER BY rank',
|
||||||
)
|
)
|
||||||
).all(),
|
).all(),
|
||||||
]);
|
]);
|
||||||
const pointForRank = new Map(
|
const pointForRank = new Map(
|
||||||
rankProfiles.map((profile) => [
|
rankProfiles.map((profile) => [
|
||||||
Number(profile.rank),
|
Number(profile.rank),
|
||||||
@@ -69,6 +83,7 @@ export async function refreshSpecializationFocusReady(
|
|||||||
Number(focus.active) === 1 &&
|
Number(focus.active) === 1 &&
|
||||||
abilityCount === 3 &&
|
abilityCount === 3 &&
|
||||||
chipReadyCount === 3 &&
|
chipReadyCount === 3 &&
|
||||||
|
Number(profileRow?.ready) === 1 &&
|
||||||
nodes.length > 0 &&
|
nodes.length > 0 &&
|
||||||
contiguousRows &&
|
contiguousRows &&
|
||||||
pointTotal === availablePoints;
|
pointTotal === availablePoints;
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
normalizeSkillChipProfiles,
|
||||||
|
SKILL_CHIP_PRIMARY_ATTRIBUTE_NAMES,
|
||||||
|
SKILL_CHIP_QUALITIES,
|
||||||
|
SKILL_CHIP_SECONDARY_CAPACITY,
|
||||||
|
SKILL_CHIP_SECONDARY_QUALITIES,
|
||||||
|
} from './profiles';
|
||||||
|
|
||||||
|
const primaryIds = [
|
||||||
|
'skill-duration',
|
||||||
|
'skill-radius',
|
||||||
|
'skill-cooldown-recovery',
|
||||||
|
'skill-intensity',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const ranges = (qualities: readonly string[], offset = 0) =>
|
||||||
|
Object.fromEntries(
|
||||||
|
qualities.map((quality, index) => [
|
||||||
|
quality,
|
||||||
|
{ min: String(offset + index + 1), max: offset + index + 2 },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const profile = (statId: string, qualities: readonly string[], offset = 0) => ({
|
||||||
|
statId,
|
||||||
|
values: ranges(qualities, offset),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Skill Chip shared profiles', () => {
|
||||||
|
it('defines the exact four fixed primary Attributes and all four qualities', () => {
|
||||||
|
expect(SKILL_CHIP_PRIMARY_ATTRIBUTE_NAMES).toEqual([
|
||||||
|
'Skill Duration',
|
||||||
|
'Skill Radius',
|
||||||
|
'Skill Cooldown Recovery',
|
||||||
|
'Skill Intensity',
|
||||||
|
]);
|
||||||
|
expect(SKILL_CHIP_QUALITIES).toEqual([
|
||||||
|
'Standard',
|
||||||
|
'Specialized',
|
||||||
|
'Superior',
|
||||||
|
'High-end',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defines the 0/1/2/2 secondary capacity mapping', () => {
|
||||||
|
expect(SKILL_CHIP_SECONDARY_CAPACITY).toEqual({
|
||||||
|
Standard: 0,
|
||||||
|
Specialized: 1,
|
||||||
|
Superior: 2,
|
||||||
|
'High-end': 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes exactly the four fixed primary profiles across every quality', () => {
|
||||||
|
const raw = primaryIds.map((statId, index) =>
|
||||||
|
profile(` ${statId} `, SKILL_CHIP_QUALITIES, index * 10),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = normalizeSkillChipProfiles(raw, SKILL_CHIP_QUALITIES, {
|
||||||
|
expectedStatIds: primaryIds,
|
||||||
|
minimumOptions: 4,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.error).toBeNull();
|
||||||
|
expect(result.profiles).toHaveLength(4);
|
||||||
|
expect(result.profiles.map(({ statId }) => statId)).toEqual(primaryIds);
|
||||||
|
expect(result.profiles[0]).toEqual({
|
||||||
|
statId: 'skill-duration',
|
||||||
|
values: {
|
||||||
|
Standard: { min: 1, max: 2 },
|
||||||
|
Specialized: { min: 2, max: 3 },
|
||||||
|
Superior: { min: 3, max: 4 },
|
||||||
|
'High-end': { min: 4, max: 5 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a primary profile that does not contain the exact fixed set', () => {
|
||||||
|
const raw = primaryIds.map((statId) =>
|
||||||
|
profile(statId, SKILL_CHIP_QUALITIES),
|
||||||
|
);
|
||||||
|
raw[3] = profile('weapon-damage', SKILL_CHIP_QUALITIES);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeSkillChipProfiles(raw, SKILL_CHIP_QUALITIES, {
|
||||||
|
expectedStatIds: primaryIds,
|
||||||
|
minimumOptions: 4,
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
profiles: [],
|
||||||
|
error:
|
||||||
|
'The primary profile must contain the four fixed Skill Attributes.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes secondary profiles across only the three supported qualities', () => {
|
||||||
|
expect(SKILL_CHIP_SECONDARY_QUALITIES).toEqual([
|
||||||
|
'Specialized',
|
||||||
|
'Superior',
|
||||||
|
'High-end',
|
||||||
|
]);
|
||||||
|
const result = normalizeSkillChipProfiles(
|
||||||
|
[
|
||||||
|
profile(' critical-hit-chance ', SKILL_CHIP_SECONDARY_QUALITIES),
|
||||||
|
profile('critical-hit-damage', SKILL_CHIP_SECONDARY_QUALITIES, 10),
|
||||||
|
],
|
||||||
|
SKILL_CHIP_SECONDARY_QUALITIES,
|
||||||
|
{ minimumOptions: 2 },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.error).toBeNull();
|
||||||
|
expect(result.profiles[0]).toEqual({
|
||||||
|
statId: 'critical-hit-chance',
|
||||||
|
values: {
|
||||||
|
Specialized: { min: 1, max: 2 },
|
||||||
|
Superior: { min: 2, max: 3 },
|
||||||
|
'High-end': { min: 3, max: 4 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result.profiles[0].values).not.toHaveProperty('Standard');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects duplicate Attributes after trimming IDs', () => {
|
||||||
|
const result = normalizeSkillChipProfiles(
|
||||||
|
[
|
||||||
|
profile('critical-hit-chance', SKILL_CHIP_SECONDARY_QUALITIES),
|
||||||
|
profile(' critical-hit-chance ', SKILL_CHIP_SECONDARY_QUALITIES),
|
||||||
|
],
|
||||||
|
SKILL_CHIP_SECONDARY_QUALITIES,
|
||||||
|
{ minimumOptions: 2 },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
profiles: [],
|
||||||
|
error: 'Choose a unique Attribute for every shared option.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[
|
||||||
|
'missing ranges',
|
||||||
|
{
|
||||||
|
Specialized: { min: 1, max: 2 },
|
||||||
|
Superior: { min: 2, max: 3 },
|
||||||
|
},
|
||||||
|
'High-end requires a minimum and maximum.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'reversed ranges',
|
||||||
|
{
|
||||||
|
Specialized: { min: 2, max: 1 },
|
||||||
|
Superior: { min: 2, max: 3 },
|
||||||
|
'High-end': { min: 3, max: 4 },
|
||||||
|
},
|
||||||
|
'Specialized requires a valid non-negative range.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'negative ranges',
|
||||||
|
{
|
||||||
|
Specialized: { min: -1, max: 2 },
|
||||||
|
Superior: { min: 2, max: 3 },
|
||||||
|
'High-end': { min: 3, max: 4 },
|
||||||
|
},
|
||||||
|
'Specialized requires a valid non-negative range.',
|
||||||
|
],
|
||||||
|
])('rejects %s', (_label, values, error) => {
|
||||||
|
const result = normalizeSkillChipProfiles(
|
||||||
|
[
|
||||||
|
{ statId: 'critical-hit-chance', values },
|
||||||
|
profile('critical-hit-damage', SKILL_CHIP_SECONDARY_QUALITIES),
|
||||||
|
],
|
||||||
|
SKILL_CHIP_SECONDARY_QUALITIES,
|
||||||
|
{ minimumOptions: 2 },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({ profiles: [], error });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
export const SKILL_CHIP_QUALITIES = [
|
||||||
|
'Standard',
|
||||||
|
'Specialized',
|
||||||
|
'Superior',
|
||||||
|
'High-end',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const SKILL_CHIP_SECONDARY_QUALITIES = [
|
||||||
|
'Specialized',
|
||||||
|
'Superior',
|
||||||
|
'High-end',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const SKILL_CHIP_SECONDARY_CAPACITY = {
|
||||||
|
Standard: 0,
|
||||||
|
Specialized: 1,
|
||||||
|
Superior: 2,
|
||||||
|
'High-end': 2,
|
||||||
|
} as const satisfies Record<(typeof SKILL_CHIP_QUALITIES)[number], number>;
|
||||||
|
|
||||||
|
export const SKILL_CHIP_PRIMARY_ATTRIBUTE_NAMES = [
|
||||||
|
'Skill Duration',
|
||||||
|
'Skill Radius',
|
||||||
|
'Skill Cooldown Recovery',
|
||||||
|
'Skill Intensity',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type SkillChipRange = { min: number; max: number };
|
||||||
|
export type SkillChipProfile = {
|
||||||
|
statId: string;
|
||||||
|
values: Record<string, SkillChipRange>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function hydrateSkillChipProfileRows(
|
||||||
|
options: Array<Record<string, unknown>>,
|
||||||
|
qualityValues: Array<Record<string, unknown>>,
|
||||||
|
) {
|
||||||
|
return options.map((option) => ({
|
||||||
|
statId: String(option.stat_attribute_id),
|
||||||
|
ranges: Object.fromEntries(
|
||||||
|
qualityValues
|
||||||
|
.filter((range) => range.stat_attribute_id === option.stat_attribute_id)
|
||||||
|
.map((range) => [
|
||||||
|
String(range.quality),
|
||||||
|
{ min: Number(range.min_value), max: Number(range.max_value) },
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const finite = (value: unknown) => {
|
||||||
|
if (value === '' || value === null || value === undefined) return null;
|
||||||
|
const number = Number(value);
|
||||||
|
return Number.isFinite(number) ? number : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function normalizeSkillChipProfiles(
|
||||||
|
raw: unknown,
|
||||||
|
qualities: readonly string[],
|
||||||
|
options: {
|
||||||
|
expectedStatIds?: readonly string[];
|
||||||
|
minimumOptions?: number;
|
||||||
|
} = {},
|
||||||
|
): { profiles: SkillChipProfile[]; error: string | null } {
|
||||||
|
if (!Array.isArray(raw))
|
||||||
|
return { profiles: [], error: 'The shared profile payload is invalid.' };
|
||||||
|
if (raw.length < (options.minimumOptions ?? 1) || raw.length > 50)
|
||||||
|
return {
|
||||||
|
profiles: [],
|
||||||
|
error: `Configure ${options.minimumOptions ?? 1}–50 shared Attribute options.`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const profiles: SkillChipProfile[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const entry of raw) {
|
||||||
|
if (!entry || typeof entry !== 'object' || Array.isArray(entry))
|
||||||
|
return { profiles: [], error: 'A shared Attribute option is invalid.' };
|
||||||
|
const candidate = entry as Record<string, unknown>;
|
||||||
|
const statId = String(candidate.statId ?? '').trim();
|
||||||
|
if (!statId || seen.has(statId))
|
||||||
|
return {
|
||||||
|
profiles: [],
|
||||||
|
error: 'Choose a unique Attribute for every shared option.',
|
||||||
|
};
|
||||||
|
seen.add(statId);
|
||||||
|
const rawValues = candidate.values;
|
||||||
|
if (!rawValues || typeof rawValues !== 'object' || Array.isArray(rawValues))
|
||||||
|
return {
|
||||||
|
profiles: [],
|
||||||
|
error: 'Every shared Attribute needs quality ranges.',
|
||||||
|
};
|
||||||
|
const values: Record<string, SkillChipRange> = {};
|
||||||
|
for (const quality of qualities) {
|
||||||
|
const rawRange = (rawValues as Record<string, unknown>)[quality];
|
||||||
|
if (!rawRange || typeof rawRange !== 'object' || Array.isArray(rawRange))
|
||||||
|
return {
|
||||||
|
profiles: [],
|
||||||
|
error: `${quality} requires a minimum and maximum.`,
|
||||||
|
};
|
||||||
|
const range = rawRange as Record<string, unknown>;
|
||||||
|
const min = finite(range.min);
|
||||||
|
const max = finite(range.max);
|
||||||
|
if (min === null || max === null || min < 0 || max < min)
|
||||||
|
return {
|
||||||
|
profiles: [],
|
||||||
|
error: `${quality} requires a valid non-negative range.`,
|
||||||
|
};
|
||||||
|
values[quality] = { min, max };
|
||||||
|
}
|
||||||
|
profiles.push({ statId, values });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.expectedStatIds) {
|
||||||
|
const expected = [...options.expectedStatIds].sort();
|
||||||
|
const received = profiles.map((profile) => profile.statId).sort();
|
||||||
|
if (
|
||||||
|
expected.length !== received.length ||
|
||||||
|
expected.some((statId, index) => statId !== received[index])
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
profiles: [],
|
||||||
|
error:
|
||||||
|
'The primary profile must contain the four fixed Skill Attributes.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { profiles, error: null };
|
||||||
|
}
|
||||||
@@ -1,10 +1,15 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
|
WEAPON_MOD_SLOT_DEFINITIONS,
|
||||||
|
WEAPON_MOD_SLOT_TYPES,
|
||||||
excludedTierZeroWeaponModSlot,
|
excludedTierZeroWeaponModSlot,
|
||||||
expandSharedWeaponModSlots,
|
expandSharedWeaponModSlots,
|
||||||
normalizeWeaponModSlotPayload,
|
normalizeWeaponModSlotPayload,
|
||||||
|
publicWeaponModSlots,
|
||||||
sharedWeaponModSlots,
|
sharedWeaponModSlots,
|
||||||
validateWeaponModSlots,
|
validateWeaponModSlots,
|
||||||
|
weaponModSlotDefinitionById,
|
||||||
|
weaponModSlotDefinitionByName,
|
||||||
weaponModSlotsComplete,
|
weaponModSlotsComplete,
|
||||||
type WeaponModSlotConfig,
|
type WeaponModSlotConfig,
|
||||||
} from './mod-slots';
|
} from './mod-slots';
|
||||||
@@ -15,6 +20,23 @@ const configuration = (tier: number, slots: string[]): WeaponModSlotConfig => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('weapon mod slot configuration', () => {
|
describe('weapon mod slot configuration', () => {
|
||||||
|
it('assigns one stable UUID to every canonical slot type', () => {
|
||||||
|
expect(WEAPON_MOD_SLOT_DEFINITIONS).toHaveLength(8);
|
||||||
|
expect(new Set(WEAPON_MOD_SLOT_DEFINITIONS.map(({ id }) => id)).size).toBe(
|
||||||
|
8,
|
||||||
|
);
|
||||||
|
expect(WEAPON_MOD_SLOT_DEFINITIONS.map(({ name }) => name)).toEqual(
|
||||||
|
WEAPON_MOD_SLOT_TYPES,
|
||||||
|
);
|
||||||
|
for (const definition of WEAPON_MOD_SLOT_DEFINITIONS) {
|
||||||
|
expect(definition.id).toMatch(
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
||||||
|
);
|
||||||
|
expect(weaponModSlotDefinitionById(definition.id)).toBe(definition);
|
||||||
|
expect(weaponModSlotDefinitionByName(definition.name)).toBe(definition);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('accepts missing and partial slot data for later configuration', () => {
|
it('accepts missing and partial slot data for later configuration', () => {
|
||||||
const slots = [
|
const slots = [
|
||||||
configuration(0, []),
|
configuration(0, []),
|
||||||
@@ -99,4 +121,31 @@ describe('weapon mod slot configuration', () => {
|
|||||||
]),
|
]),
|
||||||
).toEqual([configuration(1, ['Optics', 'Muzzle', 'Barrel'])]);
|
).toEqual([configuration(1, ['Optics', 'Muzzle', 'Barrel'])]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('marks the slot gained by a native T0 weapon at T1', () => {
|
||||||
|
const slots = expandSharedWeaponModSlots(
|
||||||
|
['Magazine', 'Optics', 'Barrel', 'Muzzle', 'Underbarrel'],
|
||||||
|
'Barrel',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(publicWeaponModSlots(0, slots)).toEqual([
|
||||||
|
{ slot: 'Optics', unlockedAtTierOne: false },
|
||||||
|
{ slot: 'Muzzle', unlockedAtTierOne: false },
|
||||||
|
{ slot: 'Underbarrel', unlockedAtTierOne: false },
|
||||||
|
{ slot: 'Magazine', unlockedAtTierOne: false },
|
||||||
|
{ slot: 'Barrel', unlockedAtTierOne: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows native T1 slots without an upgrade-only state', () => {
|
||||||
|
expect(
|
||||||
|
publicWeaponModSlots(1, [
|
||||||
|
configuration(1, ['Magazine', 'Optics', 'Muzzle']),
|
||||||
|
]),
|
||||||
|
).toEqual([
|
||||||
|
{ slot: 'Optics', unlockedAtTierOne: false },
|
||||||
|
{ slot: 'Muzzle', unlockedAtTierOne: false },
|
||||||
|
{ slot: 'Magazine', unlockedAtTierOne: false },
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,19 +1,42 @@
|
|||||||
export const WEAPON_MOD_SLOT_TYPES = [
|
export const WEAPON_MOD_SLOT_DEFINITIONS = [
|
||||||
'Optics',
|
{ id: '28ffc3b1-5d98-4273-a9af-67dbff3e5c76', name: 'Optics' },
|
||||||
'Muzzle',
|
{ id: 'c4598f51-2cb0-4a20-a3a7-6d8983a26598', name: 'Muzzle' },
|
||||||
'Underbarrel',
|
{ id: '85ca3493-1871-4d22-950a-ed718fd547e0', name: 'Underbarrel' },
|
||||||
'Magazine',
|
{ id: '4d809a35-8285-4bff-be98-a0effe1d257e', name: 'Magazine' },
|
||||||
'Barrel',
|
{ id: '0e157cfe-975c-441d-8f49-72983161e7b1', name: 'Barrel' },
|
||||||
'Upper Receiver',
|
{ id: '551fd7c8-3e5b-4c89-8437-9b901e89e8aa', name: 'Upper Receiver' },
|
||||||
'Lower Receiver',
|
{ id: '72573367-6e91-4312-9cc3-710ede4f078e', name: 'Lower Receiver' },
|
||||||
'Trigger',
|
{ id: '8dda5edb-6079-4a23-a556-cd9be4521c70', name: 'Trigger' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
export type WeaponModSlotType =
|
||||||
|
(typeof WEAPON_MOD_SLOT_DEFINITIONS)[number]['name'];
|
||||||
|
|
||||||
|
export const WEAPON_MOD_SLOT_TYPES = WEAPON_MOD_SLOT_DEFINITIONS.map(
|
||||||
|
(definition) => definition.name,
|
||||||
|
) as WeaponModSlotType[];
|
||||||
|
|
||||||
|
export function weaponModSlotDefinitionById(id: string) {
|
||||||
|
return WEAPON_MOD_SLOT_DEFINITIONS.find((definition) => definition.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function weaponModSlotDefinitionByName(name: string) {
|
||||||
|
return WEAPON_MOD_SLOT_DEFINITIONS.find(
|
||||||
|
(definition) => definition.name === name,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export type WeaponModSlotConfig = {
|
export type WeaponModSlotConfig = {
|
||||||
tier: number;
|
tier: number;
|
||||||
slots: string[];
|
slots: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PublicWeaponModSlot = {
|
||||||
|
slot: string;
|
||||||
|
unlockedAtTierOne: boolean;
|
||||||
|
iconUrl?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export function normalizeWeaponModSlotPayload(
|
export function normalizeWeaponModSlotPayload(
|
||||||
payload: unknown,
|
payload: unknown,
|
||||||
): WeaponModSlotConfig[] {
|
): WeaponModSlotConfig[] {
|
||||||
@@ -52,6 +75,33 @@ export function sharedWeaponModSlots(
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function publicWeaponModSlots(
|
||||||
|
nativeTier: number,
|
||||||
|
configurations: WeaponModSlotConfig[],
|
||||||
|
): PublicWeaponModSlot[] {
|
||||||
|
const nativeSlots =
|
||||||
|
configurations.find((row) => Number(row.tier) === nativeTier)?.slots ?? [];
|
||||||
|
const displayedSlots =
|
||||||
|
nativeTier === 0
|
||||||
|
? sharedWeaponModSlots(configurations).length
|
||||||
|
? sharedWeaponModSlots(configurations)
|
||||||
|
: nativeSlots
|
||||||
|
: nativeSlots;
|
||||||
|
const ordered = [...new Set(displayedSlots)].sort(
|
||||||
|
(left, right) =>
|
||||||
|
WEAPON_MOD_SLOT_TYPES.indexOf(
|
||||||
|
left as (typeof WEAPON_MOD_SLOT_TYPES)[number],
|
||||||
|
) -
|
||||||
|
WEAPON_MOD_SLOT_TYPES.indexOf(
|
||||||
|
right as (typeof WEAPON_MOD_SLOT_TYPES)[number],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return ordered.map((slot) => ({
|
||||||
|
slot,
|
||||||
|
unlockedAtTierOne: nativeTier === 0 && !nativeSlots.includes(slot),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
export function excludedTierZeroWeaponModSlot(
|
export function excludedTierZeroWeaponModSlot(
|
||||||
sharedSlots: string[],
|
sharedSlots: string[],
|
||||||
configurations: WeaponModSlotConfig[],
|
configurations: WeaponModSlotConfig[],
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user