diff --git a/AGENTS.md b/AGENTS.md index 3af3882..ba8a15e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,21 @@ or revise a reusable design decision do not require a documentation update. If uncertain whether a visual change is significant, update the design system. +## ISAC editor lifecycle + +All ISAC data-management editors must follow the same successful-save behavior: + +1. Saving an edit to an existing record closes the editor and returns the user + to the registry. +2. Saving a newly added record keeps the editor open with a fresh blank record + so consecutive data entry remains fast. +3. Validation errors keep the editor open and preserve the entered values. +4. Server actions must return the add/edit mode and a unique result token; + client-side reactive handling must consume each token only once to avoid + repeated state updates. + +Apply this convention whenever an ISAC editor is added or materially changed. + ## Database preservation Development data is valuable and may be entered by non-technical contributors. diff --git a/database/schema.sql b/database/schema.sql index 8c7b3e0..f91c5c8 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -165,6 +165,16 @@ CREATE TABLE IF NOT EXISTS talent_description_templates ( template TEXT NOT NULL, updated_at TEXT NOT NULL ); +CREATE TABLE IF NOT EXISTS talent_weapon_scopes ( + talent_id TEXT PRIMARY KEY REFERENCES talents(id) ON DELETE CASCADE, + scope TEXT NOT NULL CHECK(scope IN ('Standard', 'Exotic')) +); +INSERT OR IGNORE INTO talent_weapon_scopes(talent_id,scope) + SELECT id,'Standard' FROM talents WHERE category='Weapon'; +CREATE TABLE IF NOT EXISTS weapon_talent_slots ( + talent_id TEXT PRIMARY KEY REFERENCES talents(id) ON DELETE CASCADE, + slot INTEGER NOT NULL CHECK(slot IN (1, 2)) +); CREATE TABLE IF NOT EXISTS brand_sets ( id TEXT PRIMARY KEY, name TEXT NOT NULL, @@ -213,3 +223,63 @@ CREATE TABLE IF NOT EXISTS gear_talents ( talent_id TEXT NOT NULL REFERENCES talents(id), PRIMARY KEY(gear_id, talent_id) ); +CREATE TABLE IF NOT EXISTS weapons ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + category TEXT NOT NULL CHECK(category IN ('Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG')), + is_exotic INTEGER NOT NULL DEFAULT 0 CHECK(is_exotic IN (0, 1)), + native_tier INTEGER NOT NULL CHECK(native_tier BETWEEN 0 AND 2), + damage_type TEXT NOT NULL CHECK(damage_type IN ('Blast', 'Pierce', 'Shredding')), + rpm REAL NOT NULL CHECK(rpm >= 0), + magazine REAL NOT NULL CHECK(magazine >= 0), + reload_speed REAL NOT NULL CHECK(reload_speed >= 0), + accuracy REAL NOT NULL CHECK(accuracy >= 0), + stability REAL NOT NULL CHECK(stability >= 0), + exotic_talent_id TEXT REFERENCES talents(id), + active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0, 1)), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + CHECK( + (is_exotic = 1 AND exotic_talent_id IS NOT NULL) + OR + (is_exotic = 0 AND exotic_talent_id IS NULL) + ) +); +CREATE UNIQUE INDEX IF NOT EXISTS weapons_name_nocase_idx ON weapons(name COLLATE NOCASE); +CREATE TABLE IF NOT EXISTS weapon_tier_ranges ( + weapon_id TEXT NOT NULL REFERENCES weapons(id) ON DELETE CASCADE, + tier INTEGER NOT NULL CHECK(tier BETWEEN 0 AND 2), + damage_min REAL NOT NULL CHECK(damage_min >= 0), + damage_max REAL NOT NULL CHECK(damage_max >= damage_min), + firepower_min REAL NOT NULL CHECK(firepower_min >= 0), + firepower_max REAL NOT NULL CHECK(firepower_max >= firepower_min), + toughness_min REAL NOT NULL CHECK(toughness_min >= 0), + toughness_max REAL NOT NULL CHECK(toughness_max >= toughness_min), + engineering_min REAL NOT NULL CHECK(engineering_min >= 0), + engineering_max REAL NOT NULL CHECK(engineering_max >= engineering_min), + PRIMARY KEY(weapon_id, tier) +); +CREATE TABLE IF NOT EXISTS weapon_talent_options ( + weapon_id TEXT NOT NULL REFERENCES weapons(id) ON DELETE CASCADE, + talent_slot INTEGER NOT NULL CHECK(talent_slot IN (1, 2)), + talent_id TEXT NOT NULL REFERENCES talents(id), + PRIMARY KEY(weapon_id, talent_slot, talent_id) +); +CREATE TABLE IF NOT EXISTS weapon_augmentation_effects ( + id TEXT PRIMARY KEY, + weapon_id TEXT NOT NULL REFERENCES weapons(id) ON DELETE CASCADE, + stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id), + value REAL NOT NULL CHECK(value > 0), + sort_order INTEGER NOT NULL CHECK(sort_order IN (1, 2)), + UNIQUE(weapon_id, sort_order), + UNIQUE(weapon_id, stat_attribute_id) +); +CREATE TABLE IF NOT EXISTS weapon_base_attribute_bonuses ( + id TEXT PRIMARY KEY, + weapon_id TEXT NOT NULL REFERENCES weapons(id) ON DELETE CASCADE, + stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id), + value REAL NOT NULL CHECK(value > 0), + sort_order INTEGER NOT NULL CHECK(sort_order >= 1), + UNIQUE(weapon_id, stat_attribute_id), + UNIQUE(weapon_id, sort_order) +); diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index 6c68776..d4068b8 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -204,3 +204,38 @@ buttons for reversible identity changes and muted red controls for destructive or restrictive changes. Every control must expose pointer, hover, focus-visible, pressed, disabled, and inline pending-spinner states; adjacent navigation links use the same height, typography, and interaction motion. + +### Weapon definition editor + +Complex progression definitions use a stacked control-surface editor: identity +and fixed values first, then parallel tier cards, mutually exclusive talent +pools, fixed base bonuses, and the augmentation sequence. Tier cards retain +identical row ordering so values can be compared horizontally on wide screens +and stack without changing meaning on narrow screens. System-derived augment +steps appear as read-only numbered cells; editable effects use the shared +attribute/value row pattern. Exotic is a status layered onto a weapon family, +never a replacement for the family classification. Fixed base bonuses remain +visually separate from tactical augmentation effects. Exotic definitions add a +dedicated exotic-only talent after the two standard talent pools; their sixth +augmentation cell communicates that this talent advances to Tier II. + +Large relationship sets use the searchable assignment control instead of +checkbox walls. It keeps assigned records visible as removable chips, filters +available records by name, renders at most thirty results at once, and exposes +counts for both assignments and matches. Mutually exclusive pools pass their +assigned IDs to one another as exclusions so invalid combinations cannot be +selected. Keyboard focus, pressed state, empty results, and overflow remain +explicit. +Where a relationship has a global slot invariant, filter each assignment +control at the data boundary instead of presenting cross-slot choices. Weapon +talents are classified once as Talent 1 / Top or Talent 2 / Bottom in the +talent registry; weapon editors only curate subsets from the matching slot. + +### ISAC editor lifecycle + +Successful saves have a consistent outcome throughout data management: +editing an existing record closes the editor and returns the user to the registry; +creating a new record keeps the editor open with a fresh blank definition for +rapid consecutive entry. Failed validation leaves the current editor and +entered values intact. Use a unique action result token so reactive handling +runs exactly once and does not create update loops. diff --git a/src/app.css b/src/app.css index 29fed60..e1293d5 100644 --- a/src/app.css +++ b/src/app.css @@ -78,4 +78,7 @@ .section-label{margin-top:10px;border-top:1px solid #2c3132}.user-search{display:flex;gap:6px}.user-search input{height:40px;min-width:240px;margin:0;padding:0 10px;font-size:9px}.user-search button{height:40px}.user-row{min-width:1020px;grid-template-columns:1.1fr .75fr 1.1fr 1.1fr 1fr .3fr}.restricted{color:#df8069!important}.row-action{justify-self:end;color:#f6a821;font-size:8px;font-weight:bold}.button-link{display:grid;place-items:center;height:40px;padding:0 14px;border:1px solid #424849;color:#d7d9d5;font-size:8px;font-weight:bold}.metric-date{font-size:11px!important}.user-console-grid{display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:10px}.user-console-grid .dashboard-editor{margin-top:0}.permission-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.moderation-panel .field{display:block}.console-note{padding:20px;color:#747c7e;font-size:9px}.user-history-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.user-history-grid>section{min-width:0;border:1px solid #303536;background:#111415}.history-head{display:flex;justify-content:space-between;padding:10px;background:#0d1011;color:#727a7c;font-size:7px;letter-spacing:1px}.history-list{max-height:300px;overflow:auto}.history-list>div{display:grid;grid-template-columns:1fr auto auto;gap:8px;padding:9px 10px;border-top:1px solid #292e2f;font-size:8px}.history-list span{color:#767e80}.history-list b{font-size:7px}.history-list>p{padding:28px;text-align:center;color:#626a6c;font-size:8px}.comment-history>div{grid-template-columns:1fr}.comment-history span{white-space:normal;line-height:1.4} @media(max-width:1000px){.user-console-grid,.user-history-grid{grid-template-columns:1fr}.permission-grid{grid-template-columns:1fr 1fr}}@media(max-width:560px){.user-search{width:100%}.user-search input{min-width:0;flex:1}.permission-grid{grid-template-columns:1fr}} @media(max-width:1100px){.effect-editor{grid-template-columns:1fr}.selection-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:900px){.gear-identity,.gear-ranges{grid-template-columns:1fr 1fr}}@media(max-width:560px){.effect-band{grid-template-columns:48px 1fr}.effect-band .field:last-child{grid-column:2}.split-actions{align-items:stretch}.split-actions,.split-actions>div{width:100%}.split-actions>div:last-child button{flex:1}.gear-identity,.gear-ranges,.selection-grid{grid-template-columns:1fr}.range-band{grid-template-columns:48px 1fr}.range-band .field:last-child{grid-column:2}} +.weapon-editor{border-top-color:#d07a2c}.weapon-identity-grid{display:grid;grid-template-columns:1.6fr repeat(5,minmax(0,1fr));gap:8px}.weapon-fixed-grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:8px}.exotic-toggle>span{min-height:32px;margin-top:5px;padding:0 9px;display:flex;align-items:center;gap:7px;border:1px solid #363b3c;background:#0d0f10;color:#d3d5d1;font-size:8px}.exotic-toggle input{width:auto;margin:0;accent-color:#f6a821}.weapon-tier-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin-top:9px}.weapon-tier-card{min-width:0;margin:0;padding:7px;border:1px solid #3b4142;background:#101314}.weapon-tier-card legend{padding:0 7px;color:#f6a821;font-size:8px;font-weight:900;letter-spacing:1.2px}.weapon-range-row{display:grid;grid-template-columns:55px 1fr 1fr;gap:5px;align-items:end;padding:5px;border-top:1px solid #282e2f}.weapon-range-row:first-of-type{border-top:0}.weapon-range-row>strong{align-self:center;color:#d7d9d5;font-size:9px}.weapon-range-row>strong small{display:block;color:#596164;font-size:5px;letter-spacing:.5px}.weapon-range-row label{color:#737a7c;font-size:6px;letter-spacing:.7px}.weapon-range-row input{margin-top:3px;padding:7px;font-size:8px}.weapon-talent-pools{display:grid;grid-template-columns:1fr 1fr;gap:9px}.compact-selection{grid-template-columns:repeat(2,minmax(0,1fr));max-height:210px;overflow:auto}.weapon-base-bonuses,.weapon-augment-effects{display:grid;gap:5px;margin-top:7px}.base-bonus-row,.augment-effect{display:grid;grid-template-columns:35px minmax(180px,1fr) 130px 30px;gap:6px;align-items:center;padding:5px;border:1px solid #303536;background:#101314}.base-bonus-row>strong,.augment-effect>strong{height:100%;display:grid;place-items:center;background:#171b1c;color:#f6a821;font-size:8px}.base-bonus-row .field,.augment-effect .field{margin:0}.base-bonus-row button,.augment-effect button{height:30px;border:1px solid #563734;background:#1d1212;color:#da746c;cursor:pointer}.base-bonus-row button:disabled{opacity:.3;cursor:not-allowed}.augment-effect:not(:has(>button)){grid-template-columns:35px minmax(180px,1fr) 130px}.augment-sequence{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:5px;margin-top:7px}.augment-sequence>div{min-height:42px;display:grid;place-items:center;padding:5px;border:1px solid #303536;background:#0d1011}.augment-sequence b{color:#f6a821;font-size:8px}.augment-sequence span{color:#727a7c;font-size:7px}.augment-sequence .exotic-step{border-color:#875d25;background:#211a0e}.weapon-row{min-width:1180px;grid-template-columns:1.3fr .7fr 1.2fr .6fr .6fr .5fr .5fr .3fr}.weapon-row>span small{color:#596164}.weapon-dashboard-table{margin-top:10px} +.assignment-picker{border:1px solid #303536;background:#0d1011}.assignment-selected{display:grid;grid-template-columns:70px 1fr;min-height:45px;border-bottom:1px solid #303536}.assignment-summary{display:grid;place-items:center;background:#15191a}.assignment-summary span{color:#687073;font-size:6px;letter-spacing:1px}.assignment-summary b{color:#f6a821;font-size:14px}.assignment-chips{display:flex;align-items:center;gap:5px;overflow:auto;padding:6px}.assignment-chips>button{flex:0 0 auto;display:flex;align-items:center;gap:7px;padding:6px 8px;border:1px solid #6b5128;background:#211a0e;color:#e2cda7;font-size:7px;cursor:pointer}.assignment-chips>button:hover{border-color:#f6a821;color:#f6a821}.assignment-chips>button:active{transform:translateY(1px)}.assignment-chips small{color:#596164;font-size:7px}.assignment-search{display:block;padding:7px 8px;color:#70787a;font-size:6px;letter-spacing:1px}.assignment-search input{height:32px;margin-top:5px;padding:0 9px;font-size:9px}.assignment-results-head{height:27px;display:flex;align-items:center;justify-content:space-between;padding:0 9px;border-top:1px solid #262b2c;border-bottom:1px solid #262b2c;color:#687073;font-size:6px;letter-spacing:1px}.assignment-results-head b{color:#8a672b}.assignment-results{max-height:210px;overflow:auto}.assignment-results>button{width:100%;min-height:34px;display:flex;align-items:center;justify-content:space-between;padding:6px 9px;border:0;border-top:1px solid #24292a;background:#111415;color:#a4aaac;text-align:left;font-size:8px;cursor:pointer}.assignment-results>button:first-child{border-top:0}.assignment-results>button:hover,.assignment-results>button:focus-visible{background:#171b1c;color:#fff;box-shadow:inset 2px 0 #f6a821}.assignment-results>button.selected{background:#211a0e;color:#f6a821;box-shadow:inset 2px 0 #f6a821}.assignment-results>button b{font-size:6px;letter-spacing:.7px}.assignment-results>button:active{transform:translateY(1px);filter:brightness(.88)}.assignment-results>p{margin:0;padding:25px;text-align:center;color:#596164;font-size:7px;letter-spacing:1px} +@media(max-width:1200px){.weapon-identity-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.weapon-fixed-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.weapon-tier-grid{grid-template-columns:1fr}.weapon-talent-pools{grid-template-columns:1fr}}@media(max-width:700px){.weapon-identity-grid,.weapon-fixed-grid{grid-template-columns:1fr 1fr}.compact-selection{grid-template-columns:1fr}.base-bonus-row,.augment-effect,.augment-effect:not(:has(>button)){grid-template-columns:30px 1fr}.base-bonus-row .field:nth-of-type(2),.augment-effect .field:nth-of-type(2){grid-column:2}.base-bonus-row>button,.augment-effect>button{grid-column:2}.augment-sequence{grid-template-columns:1fr 1fr}}@media(max-width:480px){.weapon-identity-grid,.weapon-fixed-grid{grid-template-columns:1fr}.weapon-range-row{grid-template-columns:45px 1fr 1fr}} diff --git a/src/lib/components/SearchableAssignment.svelte b/src/lib/components/SearchableAssignment.svelte new file mode 100644 index 0000000..19cd0a6 --- /dev/null +++ b/src/lib/components/SearchableAssignment.svelte @@ -0,0 +1,52 @@ + + +
{emptyLabel}
+ {/each} +{trigger.id ? 'MODIFY TRIGGER' : 'NEW TRIGGER'}
CALCULATION SCRIPTReserved for the future calculation engine
{form.triggerError}
{/if}{talent.id ? 'MODIFY TALENT' : 'NEW TALENT'}
{preview('I') || 'Description preview'}
{preview('II') || 'Description preview'}
SHARED ACTIVATION TRIGGERS
diff --git a/src/routes/admin/weapons/+page.server.ts b/src/routes/admin/weapons/+page.server.ts new file mode 100644 index 0000000..a501699 --- /dev/null +++ b/src/routes/admin/weapons/+page.server.ts @@ -0,0 +1,208 @@ +import { requirePermission } from '$lib/server/auth/authorization'; +import { getSessionUser } from '$lib/server/auth/session'; +import { getDatabase } from '$lib/server/database'; +import { fail } from '@sveltejs/kit'; +import { randomUUID } from 'node:crypto'; +import type { Actions, PageServerLoad } from './$types'; + +const categories = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG']; +const damageTypes = ['Blast', 'Pierce', 'Shredding']; +const rangeNames = ['damage', 'firepower', 'toughness', 'engineering'] as const; + +export const load: PageServerLoad = async ({ cookies }) => { + await requirePermission(await getSessionUser(cookies), 'data.manage'); + const database = await getDatabase(); + const [weapons, tierRanges, talentOptions, augmentEffects, baseBonuses, talent1Options, talent2Options, exoticTalents, stats] = await Promise.all([ + (await database.prepare('SELECT * FROM weapons ORDER BY active DESC, category, name COLLATE NOCASE')).all(), + (await database.prepare('SELECT * FROM weapon_tier_ranges ORDER BY tier')).all(), + (await database.prepare('SELECT weapon_id,talent_slot,talent_id FROM weapon_talent_options')).all(), + (await database.prepare('SELECT weapon_id,stat_attribute_id,value,sort_order FROM weapon_augmentation_effects ORDER BY sort_order')).all(), + (await database.prepare('SELECT weapon_id,stat_attribute_id,value,sort_order FROM weapon_base_attribute_bonuses ORDER BY sort_order')).all(), + (await database.prepare(`SELECT talents.id,talents.name FROM talents + JOIN talent_weapon_scopes ON talent_weapon_scopes.talent_id=talents.id + JOIN weapon_talent_slots ON weapon_talent_slots.talent_id=talents.id + WHERE talents.active=1 AND talents.category='Weapon' AND talent_weapon_scopes.scope='Standard' AND weapon_talent_slots.slot=1 + ORDER BY talents.name COLLATE NOCASE`)).all(), + (await database.prepare(`SELECT talents.id,talents.name FROM talents + JOIN talent_weapon_scopes ON talent_weapon_scopes.talent_id=talents.id + JOIN weapon_talent_slots ON weapon_talent_slots.talent_id=talents.id + WHERE talents.active=1 AND talents.category='Weapon' AND talent_weapon_scopes.scope='Standard' AND weapon_talent_slots.slot=2 + ORDER BY talents.name COLLATE NOCASE`)).all(), + (await database.prepare(`SELECT talents.id,talents.name FROM talents + JOIN talent_weapon_scopes ON talent_weapon_scopes.talent_id=talents.id + WHERE talents.active=1 AND talents.category='Weapon' AND talent_weapon_scopes.scope='Exotic' + ORDER BY talents.name COLLATE NOCASE`)).all(), + (await database.prepare("SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE")).all() + ]); + return { + weapons: weapons.map((weapon) => ({ + ...weapon, + tiers: tierRanges.filter((range) => range.weapon_id === weapon.id), + talent1Ids: talentOptions.filter((option) => option.weapon_id === weapon.id && Number(option.talent_slot) === 1).map((option) => String(option.talent_id)), + talent2Ids: talentOptions.filter((option) => option.weapon_id === weapon.id && Number(option.talent_slot) === 2).map((option) => String(option.talent_id)), + augmentEffects: augmentEffects.filter((effect) => effect.weapon_id === weapon.id), + baseBonuses: baseBonuses.filter((effect) => effect.weapon_id === weapon.id) + })), + talent1Options, + talent2Options, + exoticTalents, + stats + }; +}; + +function number(form: FormData, name: string): number { + return Number(form.get(name)); +} + +function readRange(form: FormData, tier: number, range: (typeof rangeNames)[number]) { + const min = number(form, `tier${tier}${range}Min`); + const max = number(form, `tier${tier}${range}Max`); + if (!Number.isFinite(min) || !Number.isFinite(max) || min < 0 || max < min) throw new Error(); + return { min, max }; +} + +export const actions: Actions = { + save: async ({ request, cookies }) => { + await requirePermission(await getSessionUser(cookies), 'data.manage'); + const form = await request.formData(); + const id = String(form.get('id') ?? ''); + const name = String(form.get('name') ?? '').normalize('NFKC').trim(); + const category = String(form.get('category') ?? ''); + const damageType = String(form.get('damageType') ?? ''); + const isExotic = form.get('isExotic') === '1'; + const nativeTier = number(form, 'nativeTier'); + const fixed = { + rpm: number(form, 'rpm'), + magazine: number(form, 'magazine'), + reloadSpeed: number(form, 'reloadSpeed'), + accuracy: number(form, 'accuracy'), + stability: number(form, 'stability') + }; + const talent1Ids = [...new Set(form.getAll('talent1Ids').map(String))]; + const talent2Ids = [...new Set(form.getAll('talent2Ids').map(String))]; + const exoticTalentId = isExotic ? String(form.get('exoticTalentId') ?? '') : null; + + if (name.length < 2 || name.length > 80) return fail(400, { weaponError: 'Weapon names must be 2–80 characters.' }); + if (!categories.includes(category) || !damageTypes.includes(damageType) || !Number.isInteger(nativeTier) || nativeTier < 0 || nativeTier > 2) { + return fail(400, { weaponError: 'Choose a valid category, native tier, and damage type.' }); + } + if (Object.values(fixed).some((value) => !Number.isFinite(value) || value < 0)) { + return fail(400, { weaponError: 'Fixed weapon attributes require non-negative numeric values.' }); + } + if (!Number.isInteger(fixed.magazine) || !Number.isInteger(fixed.rpm)) { + return fail(400, { weaponError: 'RPM and magazine values must be whole numbers.' }); + } + if (!talent1Ids.length || !talent2Ids.length) return fail(400, { weaponError: 'Talent 1 and Talent 2 each require at least one available weapon talent.' }); + if (talent1Ids.some((talentId) => talent2Ids.includes(talentId))) { + return fail(400, { weaponError: 'Talent 1 and Talent 2 availability lists must be unique.' }); + } + if (isExotic && !exoticTalentId) { + return fail(400, { weaponError: 'Exotic weapons require a dedicated exotic talent.' }); + } + + const tiers = []; + try { + for (let tier = 0; tier <= 2; tier++) { + tiers.push({ + tier, + damage: readRange(form, tier, 'damage'), + firepower: readRange(form, tier, 'firepower'), + toughness: readRange(form, tier, 'toughness'), + engineering: readRange(form, tier, 'engineering') + }); + } + } catch { + return fail(400, { weaponError: 'Every tier range requires a non-negative minimum and a maximum greater than or equal to it.' }); + } + + const augmentEffects = []; + for (let order = 1; order <= 2; order++) { + const statId = String(form.get(`augmentStat${order}`) ?? ''); + const rawValue = String(form.get(`augmentValue${order}`) ?? '').trim(); + if (!statId && !rawValue && order === 2) continue; + const value = Number(rawValue); + if (!statId || !Number.isFinite(value) || value <= 0) return fail(400, { weaponError: `Tactical augmentation effect ${order} requires an attribute and a positive value.` }); + augmentEffects.push({ statId, value, order }); + } + if (!augmentEffects.length || new Set(augmentEffects.map((effect) => effect.statId)).size !== augmentEffects.length) { + return fail(400, { weaponError: 'The first tactical augmentation requires one or two unique attribute effects.' }); + } + const baseBonusCount = number(form, 'baseBonusCount'); + if (!Number.isInteger(baseBonusCount) || baseBonusCount < 1 || baseBonusCount > 12) { + return fail(400, { weaponError: 'Weapons require between one and twelve base attribute bonuses.' }); + } + const baseBonuses = []; + for (let order = 1; order <= baseBonusCount; order++) { + const statId = String(form.get(`baseBonusStat${order}`) ?? ''); + const value = number(form, `baseBonusValue${order}`); + if (!statId || !Number.isFinite(value) || value <= 0) return fail(400, { weaponError: `Base attribute bonus ${order} requires an attribute and a positive value.` }); + baseBonuses.push({ statId, value, order }); + } + if (new Set(baseBonuses.map((effect) => effect.statId)).size !== baseBonuses.length) { + return fail(400, { weaponError: 'Each base attribute bonus must reference a different attribute.' }); + } + + const database = await getDatabase(); + if (await (await database.prepare('SELECT id FROM weapons WHERE name=? COLLATE NOCASE AND id<>?')).get(name, id || 'new')) { + return fail(409, { weaponError: 'That weapon name already exists.' }); + } + for (const [slot, ids] of [[1, talent1Ids], [2, talent2Ids]] as const) { + const validTalents = await (await database.prepare(`SELECT COUNT(*) AS count FROM talents + JOIN talent_weapon_scopes ON talent_weapon_scopes.talent_id=talents.id + JOIN weapon_talent_slots ON weapon_talent_slots.talent_id=talents.id + WHERE talents.active=1 AND talents.category='Weapon' AND talent_weapon_scopes.scope='Standard' + AND weapon_talent_slots.slot=? AND talents.id IN (${ids.map(() => '?').join(',')})`)).get(slot, ...ids); + if (Number(validTalents?.count) !== ids.length) return fail(400, { weaponError: `One or more selected Talent ${slot} options are unavailable or assigned to the wrong slot.` }); + } + if (isExotic) { + const validExoticTalent = await (await database.prepare(`SELECT talents.id FROM talents + JOIN talent_weapon_scopes ON talent_weapon_scopes.talent_id=talents.id + WHERE talents.id=? AND talents.active=1 AND talents.category='Weapon' AND talent_weapon_scopes.scope='Exotic'`)).get(exoticTalentId); + if (!validExoticTalent) return fail(400, { weaponError: 'The selected exotic talent is unavailable.' }); + } + const statIds = [...new Set([...augmentEffects.map((effect) => effect.statId), ...baseBonuses.map((effect) => effect.statId)])]; + const validStats = await (await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`)).get(...statIds); + if (Number(validStats?.count) !== statIds.length) return fail(400, { weaponError: 'One or more tactical augmentation attributes are unavailable.' }); + + const weaponId = id || randomUUID(); + const now = new Date().toISOString(); + if (id) { + const exists = await (await database.prepare('SELECT id FROM weapons WHERE id=?')).get(id); + if (!exists) return fail(404, { weaponError: 'Weapon not found.' }); + await (await database.prepare(`UPDATE weapons SET name=?,category=?,is_exotic=?,native_tier=?,damage_type=?,rpm=?,magazine=?, + reload_speed=?,accuracy=?,stability=?,exotic_talent_id=?,active=1,updated_at=? WHERE id=?`)) + .run(name, category, isExotic ? 1 : 0, nativeTier, damageType, fixed.rpm, fixed.magazine, fixed.reloadSpeed, fixed.accuracy, fixed.stability, exoticTalentId, now, id); + } else { + await (await database.prepare(`INSERT INTO weapons(id,name,category,is_exotic,native_tier,damage_type,rpm,magazine,reload_speed, + accuracy,stability,exotic_talent_id,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)) + .run(weaponId, name, category, isExotic ? 1 : 0, nativeTier, damageType, fixed.rpm, fixed.magazine, fixed.reloadSpeed, fixed.accuracy, fixed.stability, exoticTalentId, now, now); + } + + await (await database.prepare('DELETE FROM weapon_tier_ranges WHERE weapon_id=?')).run(weaponId); + for (const tier of tiers) { + await (await database.prepare(`INSERT INTO weapon_tier_ranges(weapon_id,tier,damage_min,damage_max,firepower_min, + firepower_max,toughness_min,toughness_max,engineering_min,engineering_max) VALUES(?,?,?,?,?,?,?,?,?,?)`)) + .run(weaponId, tier.tier, tier.damage.min, tier.damage.max, tier.firepower.min, tier.firepower.max, tier.toughness.min, tier.toughness.max, tier.engineering.min, tier.engineering.max); + } + await (await database.prepare('DELETE FROM weapon_talent_options WHERE weapon_id=?')).run(weaponId); + for (const talentId of talent1Ids) await (await database.prepare('INSERT INTO weapon_talent_options(weapon_id,talent_slot,talent_id) VALUES(?,?,?)')).run(weaponId, 1, talentId); + for (const talentId of talent2Ids) await (await database.prepare('INSERT INTO weapon_talent_options(weapon_id,talent_slot,talent_id) VALUES(?,?,?)')).run(weaponId, 2, talentId); + await (await database.prepare('DELETE FROM weapon_augmentation_effects WHERE weapon_id=?')).run(weaponId); + for (const effect of augmentEffects) { + await (await database.prepare('INSERT INTO weapon_augmentation_effects(id,weapon_id,stat_attribute_id,value,sort_order) VALUES(?,?,?,?,?)')) + .run(randomUUID(), weaponId, effect.statId, effect.value, effect.order); + } + await (await database.prepare('DELETE FROM weapon_base_attribute_bonuses WHERE weapon_id=?')).run(weaponId); + for (const effect of baseBonuses) { + await (await database.prepare('INSERT INTO weapon_base_attribute_bonuses(id,weapon_id,stat_attribute_id,value,sort_order) VALUES(?,?,?,?,?)')) + .run(randomUUID(), weaponId, effect.statId, effect.value, effect.order); + } + return { weaponSaved: true, weaponMode: id ? 'edit' : 'add', resultToken: randomUUID() }; + }, + archive: async ({ request, cookies }) => { + await requirePermission(await getSessionUser(cookies), 'data.manage'); + const id = String((await request.formData()).get('id') ?? ''); + await (await (await getDatabase()).prepare('UPDATE weapons SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id); + return { weaponArchived: true }; + } +}; diff --git a/src/routes/admin/weapons/+page.svelte b/src/routes/admin/weapons/+page.svelte new file mode 100644 index 0000000..d2a8cfd --- /dev/null +++ b/src/routes/admin/weapons/+page.svelte @@ -0,0 +1,115 @@ + + +{editing.id ? 'MODIFY WEAPON' : 'NEW WEAPON'}
Create and classify at least one active Talent 1 and Talent 2 weapon talent before adding weapons.
{/if} + {#if data.stats.length === 0}Create an active attribute before configuring tactical augmentation 01.
{/if} + {#if Number(editing.is_exotic) && data.exoticTalents.length === 0}Create an active Exotic Weapon talent before adding an exotic weapon.
{/if} + {#if form?.weaponError}{form.weaponError}
{/if} +