From 6d2e8176ee0155df48e88f8df897394d88140d1f Mon Sep 17 00:00:00 2001 From: JB Date: Sat, 25 Jul 2026 21:10:08 -0400 Subject: [PATCH] Show pending ISAC contributions immediately --- docs/DESIGN_SYSTEM.md | 10 +++++ src/app.css | 2 +- src/hooks.server.ts | 36 +++++++++++++---- src/lib/server/data-moderation.ts | 34 +++++++++++++++- src/routes/isac/attributes/+page.server.ts | 39 +++++++++++++++--- src/routes/isac/attributes/+page.svelte | 8 +++- src/routes/isac/brands/+page.server.ts | 32 +++++++++++---- src/routes/isac/brands/+page.svelte | 2 +- src/routes/isac/gear/+page.server.ts | 47 +++++++++++++++------- src/routes/isac/gear/+page.svelte | 2 +- src/routes/isac/protocols/+page.server.ts | 37 ++++++++++++++--- src/routes/isac/protocols/+page.svelte | 2 +- src/routes/isac/skillchips/+page.server.ts | 28 ++++++++++--- src/routes/isac/skillchips/+page.svelte | 2 +- src/routes/isac/talents/+page.server.ts | 26 ++++++++++-- src/routes/isac/talents/+page.svelte | 2 +- src/routes/isac/weapons/+page.server.ts | 45 ++++++++++++++++----- src/routes/isac/weapons/+page.svelte | 2 +- 18 files changed, 288 insertions(+), 68 deletions(-) diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index 614fa5a..80e46d9 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -1024,6 +1024,16 @@ the live totals immediately. Trigger-dependent talents remain explicitly effects enter combined totals. This textual distinction must remain clear without relying on color. +### Pending ISAC registry records + +Pending additions appear directly in the same paginated ISAC registry as live +records and in every compatible relationship picker. They use a restrained +orange-tinted row, an explicit `PENDING` state, and proposer attribution. +Pending rows are read-only in module registries; changes remain the authority +of the Data Oversight review interface. Successful contributor submissions +refresh the registry immediately and follow the same editor lifecycle as a +direct save. Never rely on the tint alone to communicate moderation state. + Gear and weapon level are builder-instance state, never part of an ISAC item definition. Each equipped item stores its own level from 1–50 in the local or persisted build. Range endpoints are calculated from the documented level diff --git a/src/app.css b/src/app.css index 0602b54..867a234 100644 --- a/src/app.css +++ b/src/app.css @@ -144,7 +144,7 @@ .icon-editor-identity,.talent-icon-identity,.exotic-icon-identity{display:grid;grid-template-columns:var(--isac-icon-editor-width) minmax(0,1fr);gap:9px;align-items:start}.icon-editor-identity>.field{margin:0}.talent-icon-identity>.form-grid,.exotic-icon-identity>.form-grid{min-width:0}.icon-upload-field{width:var(--isac-icon-editor-width);max-width:100%;min-width:0;margin:0;padding:7px;border:1px solid var(--shd-border);background:#0d1011}.icon-upload-field legend{padding:0 5px;color:var(--shd-text-muted);font-size:7px;font-weight:900;letter-spacing:.8px}.icon-upload-preview{height:86px;display:grid;place-items:center;margin-bottom:6px;border:1px solid #292f30;background:#111415}.icon-upload-preview img{width:72px;height:72px;object-fit:contain}.icon-upload-preview>span{color:#596164;font-size:7px;letter-spacing:.8px}.icon-upload-field>label{min-height:30px;display:grid;place-items:center;border:1px solid #424849;background:#15191a;color:#d7d9d5;font-size:6px;font-weight:900;letter-spacing:.6px;cursor:pointer}.icon-upload-field>label:hover,.icon-upload-field>label:focus-within{border-color:var(--shd-orange);color:var(--shd-orange)}.icon-upload-field>label:active{transform:translateY(1px);filter:brightness(.88)}.icon-upload-field>label input[type=file]{position:absolute;width:1px;height:1px;opacity:0}.icon-upload-field>label.icon-remove{min-height:24px;display:flex;gap:5px;margin-top:5px;border-color:#563734;background:#1d1212;color:var(--shd-danger)}.icon-upload-field>label.icon-remove input{width:auto;margin:0;accent-color:var(--shd-danger)}.icon-upload-field>small{display:block;margin-top:5px;color:#596164;font-size:5px;line-height:1.4}.dashboard-editor input[name=name]{min-height:46px;padding:9px 11px;font-size:15px;font-weight:800;letter-spacing:.35px}.registry-icon-identity{display:flex!important;align-items:center;gap:7px;min-width:0}.registry-icon-identity>img,.registry-icon-identity>span{flex:0 0 30px;width:30px;height:30px;object-fit:contain}.registry-icon-identity>span{display:grid;place-items:center;border:1px solid #303536;color:#596164}.registry-icon-identity>div{display:grid;min-width:0}.registry-icon-identity>div strong,.registry-icon-identity>div small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}@media(max-width:700px){.icon-editor-identity,.talent-icon-identity,.exotic-icon-identity{grid-template-columns:1fr}.icon-upload-field{width:100%;max-width:none}.icon-upload-preview{height:70px}} .brand-editor-body{display:grid;grid-template-columns:var(--isac-icon-editor-width) minmax(0,1fr);gap:10px;margin-top:8px;align-items:start}.brand-editor-body>div{min-width:0}.brand-set-editor .brand-name{max-width:none;margin:0 0 7px}.brand-set-editor .icon-upload-field{padding:6px}.brand-set-editor .icon-upload-preview{height:58px;margin-bottom:5px}.brand-set-editor .icon-upload-preview img{width:50px;height:50px}.brand-set-editor .icon-upload-field>label{min-height:25px}.brand-set-editor .icon-upload-field>label.icon-remove{min-height:22px}.brand-set-editor .effect-band{padding:7px}.brand-set-editor .form-actions{margin-top:10px;padding-top:10px}.brand-archive-action{min-height:40px;padding:0 13px}.brand-archive-action>b{font-size:9px;transform:rotate(90deg)}@media(max-width:850px){.brand-editor-body{grid-template-columns:1fr}.brand-set-editor .icon-upload-field{width:100%;display:grid;grid-template-columns:70px 1fr;gap:5px}.brand-set-editor .icon-upload-field legend,.brand-set-editor .icon-upload-field>small{grid-column:1/3}.brand-set-editor .icon-upload-preview{grid-row:1/3;height:55px;margin:0}.brand-set-editor .icon-upload-field>label{min-height:25px}}@media(max-width:560px){.brand-set-editor .icon-upload-field{grid-template-columns:1fr}.brand-set-editor .icon-upload-field legend,.brand-set-editor .icon-upload-field>small{grid-column:auto}.brand-set-editor .icon-upload-preview{grid-row:auto}.brand-archive-action{width:100%}} .brand-archive-action.restore>b{transform:none}.moderation-action.restore{border:1px solid #3d6849;background:#111d15;color:var(--shd-success)}.moderation-action.restore:hover,.moderation-action.restore:focus-visible{border-color:#69b77d;background:#17271c;color:#a4e5b6} -.pending-reference{border-color:color-mix(in srgb,var(--shd-orange) 58%,#303536)!important;background:color-mix(in srgb,var(--shd-orange) 7%,#121516)!important}.pending-reference-label{color:var(--shd-orange);font-family:var(--font-display);font-size:.62rem;letter-spacing:.08em;white-space:nowrap} +.pending-reference,.dashboard-row.pending-record{border-color:color-mix(in srgb,var(--shd-orange) 58%,#303536)!important;background:color-mix(in srgb,var(--shd-orange) 7%,#121516)!important}.pending-reference-label,.state-dot.pending{color:var(--shd-orange);font-family:var(--font-display);font-size:.62rem;letter-spacing:.08em;white-space:nowrap}.state-dot.pending:before{background:var(--shd-orange)} .data-access-banner{justify-content:space-between}.data-access-banner>div{display:grid;gap:3px}.data-access-banner form{margin:0}.mode-toggle{min-height:28px;display:inline-flex;align-items:center;padding:0 9px;border:1px solid #4b5152;background:#15191a;color:#c7cdce;font-size:6px;font-weight:900;letter-spacing:.65px;white-space:nowrap;cursor:pointer}.mode-toggle:hover,.mode-toggle:focus-visible{border-color:var(--shd-orange);color:var(--shd-orange)}.mode-toggle:active{transform:translateY(1px);filter:brightness(.86)} .contributor-guidelines-toolbar{align-items:flex-end}.guideline-version{min-width:120px;display:grid;gap:4px;padding:8px 10px;border:1px solid var(--shd-border);background:#111415}.guideline-version span{color:var(--shd-text-muted);font-size:6px;letter-spacing:1px}.guideline-version strong{color:var(--shd-orange);font-size:9px}.contributor-guidelines{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin:10px 0}.contributor-guidelines section{min-height:122px;display:grid;grid-template-columns:34px 1fr;border:1px solid var(--shd-border);background:#111415}.contributor-guidelines section>span{display:grid;place-items:start center;padding-top:11px;border-right:1px solid var(--shd-border);color:var(--shd-orange);font-size:8px;font-weight:900}.contributor-guidelines section>div{padding:11px}.contributor-guidelines h3{margin:0 0 6px;font-size:9px;letter-spacing:.7px}.contributor-guidelines p{margin:0;color:#858d8f;font-size:8px;line-height:1.55}.contributor-acceptance{display:flex;align-items:flex-start;gap:9px;padding:11px;border:1px solid #6f511c;background:#19150e;cursor:pointer}.contributor-acceptance input{width:auto;margin:2px 0 0;accent-color:var(--shd-orange)}.contributor-acceptance span{display:grid;gap:4px}.contributor-acceptance b{color:#e3c487;font-size:8px;letter-spacing:.7px}.contributor-acceptance small{color:#917d59;font-size:7px}.form-actions a.button{display:inline-flex;align-items:center;justify-content:center;text-decoration:none}@media(max-width:760px){.contributor-guidelines{grid-template-columns:1fr}.data-access-banner{align-items:flex-start;flex-direction:column}.mode-toggle{width:100%;justify-content:center}} .command-status .data-mode-status{border-left:2px solid #667174}.command-status .data-mode-status strong{color:#b8c0c1;white-space:nowrap}.command-status .data-mode-status.contributor{border-left-color:var(--shd-orange)}.command-status .data-mode-status.contributor strong{color:#f2b64d}.command-status .data-mode-status.approver{border-left-color:#68a9c4}.command-status .data-mode-status.approver strong{color:#8ecce5}.command-status .data-mode-status.data-manager,.command-status .data-mode-status.admin{border-left-color:#75c08c}.command-status .data-mode-status.data-manager strong,.command-status .data-mode-status.admin strong{color:#8bd49f}@media(max-width:900px){.admin-command{align-items:flex-start;gap:12px}.command-status{display:grid;grid-template-columns:repeat(2,minmax(105px,1fr))}}@media(max-width:560px){.admin-command{display:grid}.command-status{width:100%}.command-status>div{min-width:0}} diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 4e17f56..a842249 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,7 +1,8 @@ import { json, type Handle, type HandleServerError } from '@sveltejs/kit'; +import { stringify } from 'devalue'; import { randomUUID } from 'node:crypto'; import { getSessionUser } from '$lib/server/auth/session'; -import { DATA_MODULES, queueDataProposal, shouldModerateDataMutation, type DataModule } from '$lib/server/data-moderation'; +import { DATA_MODULES, proposalActionResult, queueDataProposal, shouldModerateDataMutation, type DataModule } from '$lib/server/data-moderation'; export const handle: Handle = async ({ event, resolve }) => { if (event.url.pathname === '/admin' || event.url.pathname.startsWith('/admin/')) { @@ -22,14 +23,35 @@ export const handle: Handle = async ({ event, resolve }) => { } return new Response('Contributor mode must be enabled before submitting data changes.', { status: 403 }); } - const proposalId = await queueDataProposal(user, module, action, await event.request.formData()); - const target = new URL(event.url.pathname, event.url); - target.searchParams.set('proposal', proposalId); - const location = `${target.pathname}${target.search}`; + const submittedForm = await event.request.formData(); + const proposal = await queueDataProposal(user, module, action, submittedForm); if (event.request.headers.get('accept')?.includes('application/json')) { - return json({ type: 'redirect', status: 303, location }); + const result = proposalActionResult(module, action, Boolean(proposal.entityId), proposal.id); + if (module === 'attributes' && action === 'save') { + Object.assign(result, { + savedItem: { + id: proposal.entityId ?? proposal.draftEntityId, + name: String(submittedForm.get('name') ?? ''), + type: String(submittedForm.get('type') ?? ''), + category: String(submittedForm.get('category') ?? ''), + calculation_type: String(submittedForm.get('calculationType') ?? ''), + unit: String(submittedForm.get('unit') ?? ''), + active: 1, + pending: true, + proposalId: proposal.id, + proposerName: user.displayName + } + }); + } + return json({ + type: 'success', + status: 200, + data: stringify(result) + }); } - return new Response(null, { status: 303, headers: { location } }); + const target = new URL(event.url.pathname, event.url); + target.searchParams.set('proposal', proposal.id); + return new Response(null, { status: 303, headers: { location: `${target.pathname}${target.search}` } }); } } } diff --git a/src/lib/server/data-moderation.ts b/src/lib/server/data-moderation.ts index 248b30d..7cc74ee 100644 --- a/src/lib/server/data-moderation.ts +++ b/src/lib/server/data-moderation.ts @@ -8,6 +8,32 @@ import { getContributorMode } from '$lib/server/contributor-mode'; export const DATA_MODULES = ['attributes', 'talents', 'brands', 'gear', 'weapons', 'protocols', 'skillchips'] as const; export type DataModule = typeof DATA_MODULES[number]; +const proposalResultKeys: Partial>> = { + attributes: { save: { saved: 'saved', mode: 'editorMode' } }, + brands: { save: { saved: 'brandSaved', mode: 'brandMode' } }, + gear: { + save: { saved: 'gearSaved', mode: 'gearMode' }, + saveAugmentProfile: { saved: 'augmentProfileSaved' } + }, + weapons: { save: { saved: 'weaponSaved', mode: 'weaponMode' } }, + talents: { + saveTrigger: { saved: 'triggerSaved', mode: 'triggerMode' }, + saveTalent: { saved: 'talentSaved', mode: 'talentMode' } + }, + protocols: { + save: { saved: 'protocolSaved', mode: 'protocolMode' }, + saveProfile: { saved: 'profileSaved' } + }, + skillchips: { save: { saved: 'skillChipSaved', mode: 'skillChipMode' } } +}; + +export function proposalActionResult(module: DataModule, action: string, edit: boolean, proposalId: string) { + const keys = proposalResultKeys[module]?.[action]; + return keys + ? { [keys.saved]: true, ...(keys.mode ? { [keys.mode]: edit ? 'edit' : 'add' } : {}), proposalId, resultToken: randomUUID() } + : { proposalQueued: true, proposalId, resultToken: randomUUID() }; +} + type SerializedEntry = | { name: string; kind: 'text'; value: string } | { name: string; kind: 'file'; value: string; fileName: string; type: string }; @@ -84,6 +110,10 @@ export async function getPendingEntities(_userId: string, definitions: PendingDe const definition = definitions.find((item) => item.module === row.module && item.action === row.action)!; const values = serializedTextValues(String(row.payload_json)); const field = (name: string) => values.find((entry) => entry.name === name)?.value ?? ''; + const fieldValue = (name: string) => { + const matches = values.filter((entry) => entry.name === name).map((entry) => entry.value); + return matches.length > 1 ? matches : matches[0] ?? ''; + }; return { id: String(row.draft_entity_id), proposalId: String(row.id), @@ -92,7 +122,7 @@ export async function getPendingEntities(_userId: string, definitions: PendingDe kind: definition.kind, name: field('name'), proposerName: String(row.proposer_name), - ...Object.fromEntries((definition.fields ?? []).map((name) => [name, field(name)])) + ...Object.fromEntries((definition.fields ?? []).map((name) => [name, fieldValue(name)])) }; }); } @@ -124,7 +154,7 @@ export async function queueDataProposal(user: AuthUser, module: DataModule, acti before: null, after: { module, action, entityId, draftEntityId, dependencies: dependencies.map((item) => item.id), summary } }); - return id; + return { id, entityId, draftEntityId }; } export async function shouldModerateDataMutation(user: AuthUser | null) { diff --git a/src/routes/isac/attributes/+page.server.ts b/src/routes/isac/attributes/+page.server.ts index 0eabc15..a3c047d 100644 --- a/src/routes/isac/attributes/+page.server.ts +++ b/src/routes/isac/attributes/+page.server.ts @@ -6,20 +6,48 @@ import { randomUUID } from 'node:crypto'; import type { Actions, PageServerLoad } from './$types'; import { getPagination } from '$lib/server/pagination'; import { auditRecord, recordIsacAudit } from '$lib/server/audit'; +import { getPendingEntities } from '$lib/server/data-moderation'; export const load: PageServerLoad = async ({ cookies, url }) => { - await requireDataView(await getSessionUser(cookies)); + const user = await requireDataView(await getSessionUser(cookies)); const database = await getDatabase(); + const pending = (await getPendingEntities(user.id, [{ + module: 'attributes', + action: 'save', + kind: 'attribute', + fields: ['type', 'category', 'calculationType', 'unit', 'gearMin', 'gearMax', 'weaponMin', 'weaponMax', 'chipMin', 'chipMax', 'capValue'] + }])).map((stat) => ({ + ...stat, + calculation_type: stat.calculationType, + gear_min: stat.gearMin || null, + gear_max: stat.gearMax || null, + weapon_min: stat.weaponMin || null, + weapon_max: stat.weaponMax || null, + skill_chip_min: stat.chipMin || null, + skill_chip_max: stat.chipMax || null, + cap_value: stat.capValue || null + })); const summary = await (await database.prepare(`SELECT COUNT(*) AS total, SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active, SUM(CASE WHEN type='Offensive' THEN 1 ELSE 0 END) AS offensive, SUM(CASE WHEN type='Defensive' THEN 1 ELSE 0 END) AS defensive FROM stat_attributes`)).get(); const filter = ['active', 'offensive', 'defensive'].includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : ''; const where = filter === 'active' ? 'WHERE active=1' : filter === 'offensive' ? "WHERE type='Offensive'" : filter === 'defensive' ? "WHERE type='Defensive'" : ''; + const visiblePending = pending.filter((stat) => filter === 'offensive' ? String((stat as any).type) === 'Offensive' : filter === 'defensive' ? String((stat as any).type) === 'Defensive' : true); const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM stat_attributes ${where}`)).get(); - const pagination = getPagination(url, Number(filtered?.total ?? 0)); - const stats = await (await database.prepare(`SELECT * FROM stat_attributes ${where} ORDER BY active DESC, name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(pagination.perPage, pagination.offset); - return { stats: stats.map((stat) => ({ ...stat, unit: stat.unit === 'flat' ? '' : stat.unit })), summary, pagination, filter }; + const pagination = getPagination(url, Number(filtered?.total ?? 0) + visiblePending.length); + const pendingOnPage = pagination.page === 1 ? visiblePending.slice(0, pagination.perPage) : []; + const databaseOffset = Math.max(0, pagination.offset - visiblePending.length); + const databaseLimit = Math.max(0, pagination.perPage - pendingOnPage.length); + const stats = databaseLimit + ? await (await database.prepare(`SELECT * FROM stat_attributes ${where} ORDER BY active DESC, name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(databaseLimit, databaseOffset) + : []; + return { + stats: [...pendingOnPage, ...stats.map((stat) => ({ ...stat, unit: stat.unit === 'flat' ? '' : stat.unit }))], + summary: { ...summary, total: Number(summary?.total ?? 0) + pending.length, pending: pending.length }, + pagination, + filter + }; }; export const actions: Actions = { save: async ({ request, cookies }) => { @@ -41,7 +69,8 @@ export const actions: Actions = { if (id) await (await database.prepare(`UPDATE stat_attributes SET name=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?,weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?,cap_value=?,updated_at=? WHERE id=?`)).run(name,type,category,calculation,unit,...values,now,id); else await (await database.prepare(`INSERT INTO stat_attributes(id,name,type,category,calculation_type,unit,gear_min,gear_max,weapon_min,weapon_max,skill_chip_min,skill_chip_max,cap_value,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(statId,name,type,category,calculation,unit,...values,now,now); await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'attribute.updated' : 'attribute.created', entityId: statId, before, after: await auditRecord('stat_attributes', statId) }); - return { saved: true, editorMode: id ? 'edit' : 'add', resultToken: randomUUID() }; + const savedItem = await auditRecord('stat_attributes', statId); + return { saved: true, editorMode: id ? 'edit' : 'add', savedItem: { ...savedItem, unit: savedItem?.unit === 'flat' ? '' : savedItem?.unit }, resultToken: randomUUID() }; }, archive: async ({ request, cookies }) => { const operator = await requirePermission(await getSessionUser(cookies), 'data.manage'); const id = String((await request.formData()).get('id') ?? ''); diff --git a/src/routes/isac/attributes/+page.svelte b/src/routes/isac/attributes/+page.svelte index 00e1fab..e4a80d8 100644 --- a/src/routes/isac/attributes/+page.svelte +++ b/src/routes/isac/attributes/+page.svelte @@ -6,6 +6,8 @@ let { data, form } = $props(); let editing = $state(null); let handledResultToken = $state(''); + let immediateStats = $state([]); + const visibleStats = $derived([...immediateStats, ...data.stats.filter((stat: any) => !immediateStats.some((saved) => saved.id === stat.id))]); const typeOptions = ['Offensive', 'Defensive'].map((value) => ({ value, label: value })); const categoryOptions = ['Weapon', 'Skill', 'Character'].map((value) => ({ value, label: value })); const calculationOptions = ['Additive', 'Multiplicative'].map((value) => ({ value, label: value })); @@ -15,6 +17,10 @@ $effect(() => { if (form?.saved && form.resultToken && form.resultToken !== handledResultToken) { handledResultToken = form.resultToken; + if (form.savedItem) { + const savedItem = form.savedItem as any; + immediateStats = [savedItem, ...immediateStats.filter((stat) => stat.id !== savedItem.id)]; + } editing = form.editorMode === 'add' ? blank() : null; } }); @@ -24,4 +30,4 @@ {#if editing}

{editing.id?'MODIFY RECORD':'NEW RECORD'}

{editing.id?editing.name:'ATTRIBUTE DEFINITION'}

{#each [['gear','GEAR'],['weapon','WEAPON'],['skill_chip','SKILL CHIP']] as source}
{source[1]} RANGE · OPTIONAL
{/each}
{#if form?.error}

{form.error}

{/if}
{/if} -
ATTRIBUTECLASSIFICATIONCATEGORYCALCULATIONSTATE
{#each data.stats as stat}
{stat.name}{stat.unit || 'NO UNIT'}
{stat.type}{stat.category}{stat.calculation_type}{Number(stat.active)===1?'ACTIVE':'ARCHIVED'}
{/each}
+
ATTRIBUTECLASSIFICATIONCATEGORYCALCULATIONSTATE
{#each visibleStats as stat}
{stat.name}{stat.pending ? `PROPOSED BY ${stat.proposerName}` : stat.unit || 'NO UNIT'}
{stat.type}{stat.category}{stat.calculation_type}{stat.pending?'PENDING':Number(stat.active)===1?'ACTIVE':'ARCHIVED'}{#if stat.pending}{:else}{/if}
{/each}
diff --git a/src/routes/isac/brands/+page.server.ts b/src/routes/isac/brands/+page.server.ts index 7a6ad26..db7b510 100644 --- a/src/routes/isac/brands/+page.server.ts +++ b/src/routes/isac/brands/+page.server.ts @@ -7,12 +7,26 @@ import type { Actions, PageServerLoad } from './$types'; import { getPagination } from '$lib/server/pagination'; import { auditRecord, recordIsacAudit } from '$lib/server/audit'; import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons'; +import { getPendingEntities } from '$lib/server/data-moderation'; const thresholds = [2, 3, 4] as const; export const load: PageServerLoad = async ({ cookies, url }) => { - await requireDataView(await getSessionUser(cookies)); + const user = await requireDataView(await getSessionUser(cookies)); const database = await getDatabase(); + const [pendingBrands, pendingStats] = await Promise.all([ + getPendingEntities(user.id, [{ module: 'brands', action: 'save', kind: 'brand', fields: ['stat2', 'value2', 'stat3', 'value3', 'stat4', 'value4'] }]), + getPendingEntities(user.id, [{ module: 'attributes', action: 'save', kind: 'attribute', fields: ['unit'] }]) + ]); + const mappedPendingBrands = pendingBrands.map((brand) => ({ + ...brand, + iconUrl: '', + effects: thresholds.map((pieces) => ({ + pieces_required: pieces, + stat_attribute_id: String(brand[`stat${pieces}`] ?? ''), + value: Number(brand[`value${pieces}`] ?? 0) + })) + })); const summary = await (await database.prepare(`SELECT COUNT(*) AS total, SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active, SUM(CASE WHEN active=0 THEN 1 ELSE 0 END) AS archived, @@ -21,21 +35,25 @@ export const load: PageServerLoad = async ({ cookies, url }) => { const where = filter === 'active' ? 'WHERE active=1' : filter === 'archived' ? 'WHERE active=0' : filter === 'complete' ? 'WHERE (SELECT COUNT(*) FROM brand_set_effects WHERE brand_set_id=brand_sets.id)=3' : ''; const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM brand_sets ${where}`)).get(); - const pagination = getPagination(url, Number(filtered?.total ?? 0)); + const visiblePending = mappedPendingBrands.filter(() => filter !== 'archived'); + const pagination = getPagination(url, Number(filtered?.total ?? 0) + visiblePending.length); + const pendingOnPage = pagination.page === 1 ? visiblePending.slice(0, pagination.perPage) : []; + const databaseOffset = Math.max(0, pagination.offset - visiblePending.length); + const databaseLimit = Math.max(0, pagination.perPage - pendingOnPage.length); const [brands, effects, stats, effectSummary, icons] = await Promise.all([ - (await database.prepare(`SELECT * FROM brand_sets ${where} ORDER BY active DESC, name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(pagination.perPage, pagination.offset), + databaseLimit ? (await database.prepare(`SELECT * FROM brand_sets ${where} ORDER BY active DESC, name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(databaseLimit, databaseOffset) : [], (await database.prepare('SELECT brand_set_id,pieces_required,stat_attribute_id,value FROM brand_set_effects ORDER BY pieces_required')).all(), (await database.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(), (await database.prepare('SELECT COUNT(*) AS total FROM brand_set_effects')).get(), (await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='brand_set'")).all() ]); return { - brands: brands.map((brand) => { + brands: [...pendingOnPage, ...brands.map((brand) => { const icon = icons.find((item) => item.entity_id === brand.id); return { ...brand, iconUrl: icon ? gameDataIconUrl('brand_set', String(brand.id), icon.updated_at) : '', effects: effects.filter((effect) => effect.brand_set_id === brand.id) }; - }), - stats, - summary: { ...summary, effects: Number(effectSummary?.total ?? 0) }, + })], + stats: [...pendingStats, ...stats], + summary: { ...summary, total: Number(summary?.total ?? 0) + pendingBrands.length, pending: pendingBrands.length, effects: Number(effectSummary?.total ?? 0) }, pagination, filter }; diff --git a/src/routes/isac/brands/+page.svelte b/src/routes/isac/brands/+page.svelte index 71dc587..fd56b7d 100644 --- a/src/routes/isac/brands/+page.svelte +++ b/src/routes/isac/brands/+page.svelte @@ -58,7 +58,7 @@
BRAND SET2 PIECES3 PIECES4+ PIECESSTATE
{#each data.brands as brand} -
{#if brand.iconUrl}{:else}◇{/if}
{brand.name}{brand.effects.length}/3 EFFECTS CONFIGURED
{#each [2, 3, 4] as pieces}{@const effect = effectFor(brand, pieces)}{effect.stat_attribute_id ? formatEffect(effect) : 'NOT CONFIGURED'}{/each}{Number(brand.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}
+
{#if brand.iconUrl}{:else}◇{/if}
{brand.name}{brand.pending ? `PROPOSED BY ${brand.proposerName}` : `${brand.effects.length}/3 EFFECTS CONFIGURED`}
{#each [2, 3, 4] as pieces}{@const effect = effectFor(brand, pieces)}{effect.stat_attribute_id ? formatEffect(effect) : 'NOT CONFIGURED'}{/each}{brand.pending ? 'PENDING' : Number(brand.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}{#if brand.pending}{:else}{/if}
{:else}
NO BRAND SETS REGISTEREDAdd the first set to begin mapping equipment synergy bonuses.
{/each} diff --git a/src/routes/isac/gear/+page.server.ts b/src/routes/isac/gear/+page.server.ts index 581ab8e..cc132ed 100644 --- a/src/routes/isac/gear/+page.server.ts +++ b/src/routes/isac/gear/+page.server.ts @@ -37,8 +37,21 @@ export const load: PageServerLoad = async ({ cookies, url }) => { : filter === 'talented' ? 'WHERE EXISTS (SELECT 1 FROM gear_talents WHERE gear_id=gear.id)' : ''; const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM gear ${where}`)).get(); const pagination = getPagination(url, Number(filtered?.total ?? 0)); - const [gear, brandLinks, talentLinks, brands, talents, augmentRows, augmentCosts, stats, brandIcons, pending] = await Promise.all([ - (await database.prepare(`SELECT * FROM gear ${where} ORDER BY active DESC, slot, name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(pagination.perPage, pagination.offset), + const pendingDefinitions = await getPendingEntities(user.id, [ + { module: 'brands', action: 'save', kind: 'brand' }, + { module: 'talents', action: 'saveTalent', kind: 'talent', fields: ['category'] }, + { module: 'attributes', action: 'save', kind: 'attribute', fields: ['unit'] }, + { module: 'gear', action: 'save', kind: 'gear', fields: ['slot', 'tier', 'healthMin', 'healthMax', 'armorMin', 'armorMax', 'brandIds', 'talentIds'] } + ]); + const pendingGear = pendingDefinitions.filter((item) => item.kind === 'gear').filter((item) => + filter === 'armored' ? armoredSlots.has(String(item.slot)) : filter === 'talented' ? ['Backpack', 'Body Armor'].includes(String(item.slot)) : true + ); + const adjustedPagination = getPagination(url, Number(filtered?.total ?? 0) + pendingGear.length); + const pendingGearOnPage = adjustedPagination.page === 1 ? pendingGear.slice(0, adjustedPagination.perPage) : []; + const databaseOffset = Math.max(0, adjustedPagination.offset - pendingGear.length); + const databaseLimit = Math.max(0, adjustedPagination.perPage - pendingGearOnPage.length); + const [gear, brandLinks, talentLinks, brands, talents, augmentRows, augmentCosts, stats, brandIcons] = await Promise.all([ + databaseLimit ? (await database.prepare(`SELECT * FROM gear ${where} ORDER BY active DESC, slot, name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(databaseLimit, databaseOffset) : [], (await database.prepare('SELECT gear_id,brand_set_id FROM gear_brand_sets')).all(), (await database.prepare('SELECT gear_id,talent_id FROM gear_talents')).all(), (await database.prepare('SELECT id,name FROM brand_sets WHERE active=1 ORDER BY name COLLATE NOCASE')).all(), @@ -46,20 +59,26 @@ export const load: PageServerLoad = async ({ cookies, url }) => { (await database.prepare('SELECT * FROM gear_tactical_augments ORDER BY gear_slot,augment_slot')).all(), (await database.prepare('SELECT * FROM gear_tactical_augment_costs ORDER BY gear_tier')).all(), (await database.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(), - (await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='brand_set'")).all(), - getPendingEntities(user.id, [ - { module: 'brands', action: 'save', kind: 'brand' }, - { module: 'talents', action: 'saveTalent', kind: 'talent', fields: ['category'] } - ]) + (await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='brand_set'")).all() ]); - const pendingBrands = pending.filter((item) => item.kind === 'brand'); - const pendingTalents = pending.filter((item) => item.kind === 'talent' && ['Backpack', 'Body Armor'].includes(String(item.category))); + const pendingBrands = pendingDefinitions.filter((item) => item.kind === 'brand'); + const pendingTalents = pendingDefinitions.filter((item) => item.kind === 'talent' && ['Backpack', 'Body Armor'].includes(String(item.category))); + const pendingStats = pendingDefinitions.filter((item) => item.kind === 'attribute'); return { - gear: gear.map((item) => ({ + gear: [...pendingGearOnPage.map((item) => ({ + ...item, + tier: Number(item.tier), + base_health_min: item.healthMin, + base_health_max: item.healthMax, + base_armor_min: item.armorMin || null, + base_armor_max: item.armorMax || null, + brandIds: Array.isArray(item.brandIds) ? item.brandIds : [item.brandIds].filter(Boolean), + talentIds: Array.isArray(item.talentIds) ? item.talentIds : [item.talentIds].filter(Boolean) + })), ...gear.map((item) => ({ ...item, brandIds: brandLinks.filter((link) => link.gear_id === item.id).map((link) => String(link.brand_set_id)), talentIds: talentLinks.filter((link) => link.gear_id === item.id).map((link) => String(link.talent_id)) - })), + }))], brands: [...pendingBrands, ...brands.map((brand) => { const icon = brandIcons.find((item) => item.entity_id === brand.id); return { ...brand, iconUrl: icon ? gameDataIconUrl('brand_set', String(brand.id), icon.updated_at) : '' }; @@ -73,9 +92,9 @@ export const load: PageServerLoad = async ({ cookies, url }) => { costs: augmentCosts.filter((cost) => cost.augment_id === augment.id) })) })), - stats, - summary, - pagination, + stats: [...pendingStats, ...stats], + summary: { ...summary, total: Number(summary?.total ?? 0) + pendingDefinitions.filter((item) => item.kind === 'gear').length, pending: pendingDefinitions.filter((item) => item.kind === 'gear').length }, + pagination: adjustedPagination, filter }; }; diff --git a/src/routes/isac/gear/+page.svelte b/src/routes/isac/gear/+page.svelte index cf05f72..e1d367c 100644 --- a/src/routes/isac/gear/+page.svelte +++ b/src/routes/isac/gear/+page.svelte @@ -135,6 +135,6 @@
GEARSLOTTIERBASE RANGESBRAND SETSSTATE
{#each data.gear as item} -
{item.name}{item.talentIds.length ? `${item.talentIds.length} TALENT${item.talentIds.length === 1 ? '' : 'S'}` : item.id.slice(0, 8).toUpperCase()}
{item.slot}T{item.tier}HP {item.base_health_min}–{item.base_health_max}{#if item.base_armor_min !== null} · AR {item.base_armor_min}–{item.base_armor_max}{/if}
{#each compatibleBrands(item.brandIds) as brand}{#if brand.iconUrl}{brand.name}/{:else}{brand.name}{/if}{/each}
{Number(item.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}
+
{item.name}{item.pending ? `PROPOSED BY ${item.proposerName}` : item.talentIds.length ? `${item.talentIds.length} TALENT${item.talentIds.length === 1 ? '' : 'S'}` : item.id.slice(0, 8).toUpperCase()}
{item.slot}T{item.tier}HP {item.base_health_min}–{item.base_health_max}{#if item.base_armor_min !== null} · AR {item.base_armor_min}–{item.base_armor_max}{/if}
{#each compatibleBrands(item.brandIds) as brand}{#if brand.iconUrl}{brand.name}/{:else}{brand.name}{/if}{/each}
{item.pending ? 'PENDING' : Number(item.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}{#if item.pending}{:else}{/if}
{:else}
NO GEAR REGISTEREDAdd the first gear definition after configuring at least two brand sets.
{/each}
diff --git a/src/routes/isac/protocols/+page.server.ts b/src/routes/isac/protocols/+page.server.ts index 72ed39a..3544660 100644 --- a/src/routes/isac/protocols/+page.server.ts +++ b/src/routes/isac/protocols/+page.server.ts @@ -7,6 +7,7 @@ import { fail } from '@sveltejs/kit'; import { randomUUID } from 'node:crypto'; import type { Actions, PageServerLoad } from './$types'; import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons'; +import { getPendingEntities } from '$lib/server/data-moderation'; const qualities = ['Standard', 'Specialized', 'Superior', 'High-end']; const attributeTypes = ['Firepower', 'Toughness', 'Engineering']; @@ -48,7 +49,7 @@ async function profileSnapshot(id: string) { } export const load: PageServerLoad = async ({ cookies, url }) => { - await requireDataView(await getSessionUser(cookies)); + const user = await requireDataView(await getSessionUser(cookies)); const database = await getDatabase(); const requestedFilter = url.searchParams.get('filter') ?? ''; const filter = [...qualities.map((quality) => quality.toLowerCase()), ...attributeTypes.map((type) => type.toLowerCase()), 'active', 'archived'].includes(requestedFilter) @@ -75,7 +76,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { (await database.prepare(`SELECT COUNT(*) AS total FROM os_protocols ${where}`)).get(...args) ]); const pagination = getPagination(url, Number(filtered?.total ?? 0)); - const [protocols, primaryEffects, profiles, secondaryEffects, talents, talentTriggers, talentEffects, stats, triggers, icons] = await Promise.all([ + const [protocols, primaryEffects, profiles, secondaryEffects, talents, talentTriggers, talentEffects, stats, triggers, icons, pending] = await Promise.all([ (await database.prepare(`SELECT * FROM os_protocols ${where} ORDER BY active DESC,quality,name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset), (await database.prepare('SELECT * FROM os_protocol_primary_effects')).all(), (await database.prepare('SELECT * FROM os_protocol_secondary_profiles ORDER BY quality,primary_attribute_type')).all(), @@ -85,11 +86,33 @@ export const load: PageServerLoad = async ({ cookies, url }) => { (await database.prepare('SELECT * FROM os_protocol_talent_effects ORDER BY sort_order')).all(), (await database.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(), (await database.prepare('SELECT id,name,description FROM talent_triggers WHERE active=1 ORDER BY name COLLATE NOCASE')).all(), - (await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='os_protocol'")).all() + (await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='os_protocol'")).all(), + getPendingEntities(user.id, [ + { module: 'attributes', action: 'save', kind: 'attribute', fields: ['unit'] }, + { module: 'talents', action: 'saveTrigger', kind: 'trigger', fields: ['description'] }, + { module: 'protocols', action: 'save', kind: 'protocol', fields: ['quality', 'attributeType', 'primaryStatId', 'primaryBaseValue', 'talentName', 'descriptionTemplate', 'talentEffects', 'triggerIds'] } + ]) ]); const configuredProfiles = profiles.map((profile) => ({ ...profile, effects: secondaryEffects.filter((effect) => effect.profile_id === profile.id) })); + const pendingProtocols = pending.filter((item) => item.kind === 'protocol').filter((item) => + filter === 'archived' ? false + : qualities.some((quality) => quality.toLowerCase() === filter) ? String(item.quality).toLowerCase() === filter + : attributeTypes.some((type) => type.toLowerCase() === filter) ? String(item.attributeType).toLowerCase() === filter : true + ).map((protocol) => ({ + ...protocol, + iconUrl: '', + primary_attribute_type: protocol.attributeType, + primaryEffect: { stat_attribute_id: protocol.primaryStatId, base_value: Number(protocol.primaryBaseValue) }, + secondaryProfile: configuredProfiles.find((profile) => profile.quality === protocol.quality && profile.primary_attribute_type === protocol.attributeType) ?? null, + talent: { + name: protocol.talentName, + description_template: protocol.descriptionTemplate, + effects: (() => { try { return JSON.parse(String(protocol.talentEffects ?? '[]')); } catch { return []; } })(), + triggerIds: Array.isArray(protocol.triggerIds) ? protocol.triggerIds : [protocol.triggerIds].filter(Boolean) + } + })); return { - protocols: protocols.map((protocol) => { + protocols: [...pendingProtocols, ...protocols.map((protocol) => { const talent = talents.find((item) => item.protocol_id === protocol.id); return { ...protocol, @@ -102,11 +125,13 @@ export const load: PageServerLoad = async ({ cookies, url }) => { effects: talentEffects.filter((item) => item.talent_id === talent.id) } : null }; - }), + })], profiles: qualities.flatMap((quality) => attributeTypes.map((attributeType) => configuredProfiles.find((profile) => profile.quality === quality && profile.primary_attribute_type === attributeType ) ?? { id: '', quality, primary_attribute_type: attributeType, effects: [] })), - stats, triggers, summary, filter, pagination + stats: [...pending.filter((item) => item.kind === 'attribute'), ...stats], + triggers: [...pending.filter((item) => item.kind === 'trigger'), ...triggers], + summary: { ...summary, total: Number(summary?.total ?? 0) + pending.filter((item) => item.kind === 'protocol').length, pending: pending.filter((item) => item.kind === 'protocol').length }, filter, pagination }; }; diff --git a/src/routes/isac/protocols/+page.svelte b/src/routes/isac/protocols/+page.svelte index 3e13b8b..00a7011 100644 --- a/src/routes/isac/protocols/+page.svelte +++ b/src/routes/isac/protocols/+page.svelte @@ -166,6 +166,6 @@
OS PROTOCOLQUALITYTYPEUNIQUE PRIMARYUNIQUE TALENTSTATE
{#each data.protocols as protocol} -
{#if protocol.iconUrl}{:else}◇{/if}
{protocol.name}LEVEL 1–50 · PRIMARY 2× MAX
{protocol.quality}{protocol.primary_attribute_type}{protocol.primaryEffect ? `${stat(protocol.primaryEffect.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(protocol.primaryEffect.base_value, protocol.primaryEffect.stat_attribute_id)} → ${format(levelValue(protocol.primaryEffect.base_value, 50), protocol.primaryEffect.stat_attribute_id)}` : 'NOT CONFIGURED'}
{protocol.talent?.name ?? 'NOT CONFIGURED'}◆◆◆◆◆ 5 UPGRADES · 2× MAX
{Number(protocol.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}
+
{#if protocol.iconUrl}{:else}◇{/if}
{protocol.name}{protocol.pending ? `PROPOSED BY ${protocol.proposerName}` : 'LEVEL 1–50 · PRIMARY 2× MAX'}
{protocol.quality}{protocol.primary_attribute_type}{protocol.primaryEffect ? `${stat(protocol.primaryEffect.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(protocol.primaryEffect.base_value, protocol.primaryEffect.stat_attribute_id)} → ${format(levelValue(protocol.primaryEffect.base_value, 50), protocol.primaryEffect.stat_attribute_id)}` : 'NOT CONFIGURED'}
{protocol.talent?.name ?? 'NOT CONFIGURED'}◆◆◆◆◆ 5 UPGRADES · 2× MAX
{protocol.pending ? 'PENDING' : Number(protocol.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}{#if protocol.pending}{:else}{/if}
{:else}
NO OS PROTOCOLS REGISTEREDConfigure shared secondary profiles, then add the first protocol.
{/each}
diff --git a/src/routes/isac/skillchips/+page.server.ts b/src/routes/isac/skillchips/+page.server.ts index 09c2760..1ecce21 100644 --- a/src/routes/isac/skillchips/+page.server.ts +++ b/src/routes/isac/skillchips/+page.server.ts @@ -50,7 +50,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { : filter === 'archived' ? 'WHERE sets.active=0' : filter === 'configured' ? `WHERE EXISTS (SELECT 1 FROM skill_chip_two_piece_bonuses bonus WHERE bonus.skill_chip_set_id=sets.id) AND EXISTS (SELECT 1 FROM skill_chip_talents talent WHERE talent.skill_chip_set_id=sets.id)` : ''; - const [summary, filtered, pendingStats, pendingTriggers] = await Promise.all([ + const [summary, filtered, pendingStats, pendingTriggers, pendingSets] = await Promise.all([ (await database.prepare(`SELECT COUNT(*) AS total, SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active, SUM(CASE WHEN active=0 THEN 1 ELSE 0 END) AS archived, @@ -58,7 +58,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => { FROM skill_chip_sets`)).get(), (await database.prepare(`SELECT COUNT(*) AS total FROM skill_chip_sets sets ${where}`)).get(), getPendingEntities(user.id, [{ module: 'attributes', action: 'save', kind: 'stat', fields: ['unit', 'chipMin', 'chipMax'] }]), - getPendingEntities(user.id, [{ module: 'talents', action: 'saveTrigger', kind: 'trigger', fields: ['description'] }]) + getPendingEntities(user.id, [{ module: 'talents', action: 'saveTrigger', kind: 'trigger', fields: ['description'] }]), + getPendingEntities(user.id, [{ module: 'skillchips', action: 'save', kind: 'skillchip', fields: ['twoPieceStatId', 'twoPieceMin', 'twoPieceMax', 'primaryStatIds', 'secondaryStatIds', 'talentName', 'descriptionTemplate', 'talentEffects', 'triggerIds'] }]) ]); const pagination = getPagination(url, Number(filtered?.total ?? 0)); const [sets, twoPiece, primaryOptions, secondaryOptions, talents, talentTriggers, talentEffects, stats, triggers, icons] = await Promise.all([ @@ -79,8 +80,25 @@ export const load: PageServerLoad = async ({ cookies, url }) => { skill_chip_min: item.chipMin === '' ? null : Number(item.chipMin), skill_chip_max: item.chipMax === '' ? null : Number(item.chipMax) })), ...stats]; + const pendingSetRows = filter === 'archived' ? [] : pendingSets.map((set) => ({ + ...set, + iconUrl: '', + twoPiece: { + stat_attribute_id: set.twoPieceStatId, + min_value: Number(set.twoPieceMin), + max_value: Number(set.twoPieceMax) + }, + primaryStatIds: Array.isArray(set.primaryStatIds) ? set.primaryStatIds : [set.primaryStatIds].filter(Boolean), + secondaryStatIds: Array.isArray(set.secondaryStatIds) ? set.secondaryStatIds : [set.secondaryStatIds].filter(Boolean), + talent: { + name: set.talentName, + description_template: set.descriptionTemplate, + effects: (() => { try { return JSON.parse(String(set.talentEffects ?? '[]')); } catch { return []; } })(), + triggerIds: Array.isArray(set.triggerIds) ? set.triggerIds : [set.triggerIds].filter(Boolean) + } + })); return { - sets: sets.map((set) => { + sets: [...pendingSetRows, ...sets.map((set) => { const talent = talents.find((item) => item.skill_chip_set_id === set.id); const icon = icons.find((item) => item.entity_id === set.id); return { @@ -95,11 +113,11 @@ export const load: PageServerLoad = async ({ cookies, url }) => { effects: talentEffects.filter((item) => item.talent_id === talent.id) } : null }; - }), + })], stats: availableStats, rollStats: availableStats.filter((stat) => stat.skill_chip_min !== null && stat.skill_chip_max !== null), triggers: [...pendingTriggers, ...triggers], - summary, filter, pagination + summary: { ...summary, total: Number(summary?.total ?? 0) + pendingSets.length, pending: pendingSets.length }, filter, pagination }; }; diff --git a/src/routes/isac/skillchips/+page.svelte b/src/routes/isac/skillchips/+page.svelte index 5e7a35f..0f3b382 100644 --- a/src/routes/isac/skillchips/+page.svelte +++ b/src/routes/isac/skillchips/+page.svelte @@ -106,6 +106,6 @@
SKILL CHIPQUALITY2-PIECE BONUS3-PIECE TALENTROLL POOLSSTATE
{#each data.sets as item} -
{#if item.iconUrl}{:else}◇{/if}
{item.name}SUPERIOR · HIGH-END
SUPHE{item.twoPiece ? `${stat(item.twoPiece.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(item.twoPiece.min_value, item.twoPiece.stat_attribute_id)}–${format(item.twoPiece.max_value, item.twoPiece.stat_attribute_id)}` : 'NOT CONFIGURED'}
{item.talent?.name ?? 'NOT CONFIGURED'}{item.talent?.effects.length ?? 0} SCALED EFFECTS
{item.primaryStatIds.length} PRIMARY · {item.secondaryStatIds.length} SECONDARY{Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}
+
{#if item.iconUrl}{:else}◇{/if}
{item.name}{item.pending ? `PROPOSED BY ${item.proposerName}` : 'SUPERIOR · HIGH-END'}
SUPHE{item.twoPiece ? `${stat(item.twoPiece.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(item.twoPiece.min_value, item.twoPiece.stat_attribute_id)}–${format(item.twoPiece.max_value, item.twoPiece.stat_attribute_id)}` : 'NOT CONFIGURED'}
{item.talent?.name ?? 'NOT CONFIGURED'}{item.talent?.effects.length ?? 0} SCALED EFFECTS
{item.primaryStatIds.length} PRIMARY · {item.secondaryStatIds.length} SECONDARY{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}{#if item.pending}{:else}{/if}
{:else}
NO SKILL CHIP SETS REGISTEREDAdd the first Superior and High-end Skill Chip definition.
{/each}
diff --git a/src/routes/isac/talents/+page.server.ts b/src/routes/isac/talents/+page.server.ts index cb2482e..201bdd9 100644 --- a/src/routes/isac/talents/+page.server.ts +++ b/src/routes/isac/talents/+page.server.ts @@ -48,13 +48,31 @@ export const load: PageServerLoad = async ({ cookies, url }) => { FROM talents`)).get(), getPendingEntities(user.id, [ { module: 'talents', action: 'saveTrigger', kind: 'trigger', fields: ['description'] }, - { module: 'attributes', action: 'save', kind: 'stat', fields: ['unit'] } + { module: 'attributes', action: 'save', kind: 'stat', fields: ['unit'] }, + { module: 'talents', action: 'saveTalent', kind: 'talent', fields: ['category', 'descriptionTemplate', 'cooldownSeconds', 'durationSeconds', 'maxStacks'] } ]) ]); const pendingTriggers = pending.filter((item) => item.kind === 'trigger'); const pendingStats = pending.filter((item) => item.kind === 'stat'); + const pendingTalents = pending.filter((item) => item.kind === 'talent').filter((item) => + filter === 'slot1' ? item.category === 'Weapon Talent 1' + : filter === 'slot2' ? item.category === 'Weapon Talent 2' + : filter === 'backpack' ? item.category === 'Backpack' + : filter === 'body-armor' ? item.category === 'Body Armor' + : filter === 'exotic' ? false : true + ).map((item) => ({ + ...item, + iconUrl: '', + cooldown_seconds: item.cooldownSeconds || null, + duration_seconds: item.durationSeconds || null, + max_stacks: item.maxStacks || null, + descriptionTemplate: item.descriptionTemplate, + ownerWeaponId: null, + ownerWeaponName: null, + tiers: ['I', 'II'].map((tier) => ({ tier, description: String(item.descriptionTemplate || 'PENDING TALENT CONFIGURATION') })) + })); return { - talents: talents.map((talent) => ({ + talents: [...pendingTalents, ...talents.map((talent) => ({ ...talent, iconUrl: (() => { const icon = icons.find((item) => item.entity_id === talent.id); return icon ? gameDataIconUrl('talent', String(talent.id), icon.updated_at) : ''; })(), category: talent.category === 'Vest' @@ -76,11 +94,11 @@ export const load: PageServerLoad = async ({ cookies, url }) => { triggerIds: links.filter((link) => link.talent_tier_id === tier.id).map((link) => String(link.trigger_id)), effects: effects.filter((effect) => effect.talent_tier_id === tier.id) })) - })), + }))], triggers: [...pendingTriggers, ...triggers], stats: [...pendingStats, ...stats], summary: { - total: Number(totalRow?.total ?? 0), + total: Number(totalRow?.total ?? 0) + pending.filter((item) => item.kind === 'talent').length, triggers: Number(triggerSummary?.total ?? 0), slot1: Number(slotSummary?.slot1 ?? 0), slot2: Number(slotSummary?.slot2 ?? 0), diff --git a/src/routes/isac/talents/+page.svelte b/src/routes/isac/talents/+page.svelte index 687cff8..e847b1b 100644 --- a/src/routes/isac/talents/+page.svelte +++ b/src/routes/isac/talents/+page.svelte @@ -98,4 +98,4 @@ {/if} -
TALENTCATEGORYTIER I EFFECTTIER II EFFECTSTATE
{#each data.talents as item}
{#if item.iconUrl}{:else}◇{/if}
{item.name}{item.category === 'Exotic Weapon' ? `OWNED BY ${item.ownerWeaponName ?? 'UNASSIGNED WEAPON'}` : 'Tiered talent'}{#if cooldownLine(item.cooldown_seconds)}{cooldownLine(item.cooldown_seconds)}{/if}
{item.category}

{tierDescription(item.tiers, 'I')}

{tierDescription(item.tiers, 'II')}

ACTIVE{#if item.category === 'Exotic Weapon'}{#if item.ownerWeaponId}EDIT WEAPON{:else}{/if}{:else}{/if}
{/each}
+
TALENTCATEGORYTIER I EFFECTTIER II EFFECTSTATE
{#each data.talents as item}
{#if item.iconUrl}{:else}◇{/if}
{item.name}{item.pending ? `PROPOSED BY ${item.proposerName}` : item.category === 'Exotic Weapon' ? `OWNED BY ${item.ownerWeaponName ?? 'UNASSIGNED WEAPON'}` : 'Tiered talent'}{#if cooldownLine(item.cooldown_seconds)}{cooldownLine(item.cooldown_seconds)}{/if}
{item.category}

{tierDescription(item.tiers, 'I')}

{tierDescription(item.tiers, 'II')}

{item.pending ? 'PENDING' : 'ACTIVE'}{#if item.pending}{:else if item.category === 'Exotic Weapon'}{#if item.ownerWeaponId}EDIT WEAPON{:else}{/if}{:else}{/if}
{/each}
diff --git a/src/routes/isac/weapons/+page.server.ts b/src/routes/isac/weapons/+page.server.ts index 55dbc6c..e74efcd 100644 --- a/src/routes/isac/weapons/+page.server.ts +++ b/src/routes/isac/weapons/+page.server.ts @@ -7,6 +7,7 @@ import type { Actions, PageServerLoad } from './$types'; import { getPagination } from '$lib/server/pagination'; import { auditRecord, recordIsacAudit } from '$lib/server/audit'; import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons'; +import { getPendingEntities } from '$lib/server/data-moderation'; const categories = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG']; const damageTypes = ['Blast', 'Pierce', 'Shredding']; @@ -26,7 +27,7 @@ const renderTalentDescription = (template: string, effects: ExoticEffect[], unit }); export const load: PageServerLoad = async ({ cookies, url }) => { - await requireDataView(await getSessionUser(cookies)); + const user = await requireDataView(await getSessionUser(cookies)); const database = await getDatabase(); const summary = await (await database.prepare(`SELECT COUNT(*) AS total, SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active, @@ -38,7 +39,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { const whereArgs = requestedWeaponId ? [requestedWeaponId] : []; const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM weapons ${where}`)).get(...whereArgs); const pagination = getPagination(url, Number(filtered?.total ?? 0)); - const [weapons, tierRanges, talentOptions, augmentEffects, baseBonuses, talent1Options, talent2Options, stats, triggers, exoticTemplates, exoticTiers, exoticTierTriggers, exoticTierEffects, talentIcons] = await Promise.all([ + const [weapons, tierRanges, talentOptions, augmentEffects, baseBonuses, talent1Options, talent2Options, stats, triggers, exoticTemplates, exoticTiers, exoticTierTriggers, exoticTierEffects, talentIcons, pending] = await Promise.all([ (await database.prepare(`SELECT * FROM weapons ${where} ORDER BY active DESC, category, name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(...whereArgs, pagination.perPage, pagination.offset), (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(), @@ -63,10 +64,34 @@ export const load: PageServerLoad = async ({ cookies, url }) => { FROM talent_tiers JOIN talents ON talents.id=talent_tiers.talent_id WHERE talents.category='Weapon'`)).all(), (await database.prepare('SELECT talent_tier_id,trigger_id FROM talent_tier_triggers')).all(), (await database.prepare('SELECT talent_tier_id,stat_attribute_id,value FROM talent_tier_effects')).all(), - (await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='talent'")).all() + (await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='talent'")).all(), + getPendingEntities(user.id, [ + { module: 'attributes', action: 'save', kind: 'attribute', fields: ['unit'] }, + { module: 'talents', action: 'saveTrigger', kind: 'trigger', fields: ['description'] }, + { module: 'talents', action: 'saveTalent', kind: 'talent', fields: ['category'] }, + { module: 'weapons', action: 'save', kind: 'weapon', fields: ['category', 'nativeTier', 'damageType', 'isExotic', 'rpm', 'magazine', 'reloadSpeed', 'accuracy', 'stability', 'talent1Ids', 'talent2Ids'] } + ]) ]); + const pendingTalents = pending.filter((item) => item.kind === 'talent'); + const pendingWeapons = pending.filter((item) => item.kind === 'weapon').filter((item) => + requestedWeaponId ? item.id === requestedWeaponId + : filter === 'standard' ? !item.isExotic + : filter === 'exotic' ? Boolean(item.isExotic) : true + ).map((weapon) => ({ + ...weapon, + native_tier: Number(weapon.nativeTier), + damage_type: weapon.damageType, + is_exotic: weapon.isExotic ? 1 : 0, + reload_speed: weapon.reloadSpeed, + baseBonuses: [], + tiers: [], + talent1Ids: Array.isArray(weapon.talent1Ids) ? weapon.talent1Ids : [weapon.talent1Ids].filter(Boolean), + talent2Ids: Array.isArray(weapon.talent2Ids) ? weapon.talent2Ids : [weapon.talent2Ids].filter(Boolean), + augmentEffects: [], + exoticTalent: null + })); return { - weapons: weapons.map((weapon) => { + weapons: [...pendingWeapons, ...weapons.map((weapon) => { const talentId = typeof weapon.exotic_talent_id === 'string' ? weapon.exotic_talent_id : ''; const talentRows = exoticTiers.filter((tier) => tier.talent_id === talentId); const tier = (name: string) => talentRows.find((item) => item.tier === name); @@ -100,12 +125,12 @@ export const load: PageServerLoad = async ({ cookies, url }) => { valueII: exoticTierEffects.find((effect) => effect.talent_tier_id === tierII?.id && String(effect.stat_attribute_id) === statId)?.value ?? '' })) } : null - }}), - talent1Options, - talent2Options, - stats, - triggers, - summary, + }})], + talent1Options: [...pendingTalents.filter((item) => item.category === 'Weapon Talent 1'), ...talent1Options], + talent2Options: [...pendingTalents.filter((item) => item.category === 'Weapon Talent 2'), ...talent2Options], + stats: [...pending.filter((item) => item.kind === 'attribute'), ...stats], + triggers: [...pending.filter((item) => item.kind === 'trigger'), ...triggers], + summary: { ...summary, total: Number(summary?.total ?? 0) + pending.filter((item) => item.kind === 'weapon').length, pending: pending.filter((item) => item.kind === 'weapon').length }, pagination, filter, requestedWeaponId diff --git a/src/routes/isac/weapons/+page.svelte b/src/routes/isac/weapons/+page.svelte index e4380ae..0284781 100644 --- a/src/routes/isac/weapons/+page.svelte +++ b/src/routes/isac/weapons/+page.svelte @@ -162,6 +162,6 @@
WEAPONCLASSFIXED ATTRIBUTESTIER RANGETALENT POOLSAUGMENTSSTATE
{#each data.weapons as weapon} -
{weapon.name}{Number(weapon.is_exotic) ? 'EXOTIC · ' : ''}{weapon.damage_type.toUpperCase()} DAMAGE · {weapon.baseBonuses.length} BASE BONUSES
{#if Number(weapon.is_exotic)}{/if}{weapon.category} · T{weapon.native_tier}{weapon.rpm} RPM · {weapon.magazine} MAG · {weapon.reload_speed} RELOAD{weapon.tiers.length}/{3 - Number(weapon.native_tier)} CONFIGURED{weapon.talent1Ids.length} / {weapon.talent2Ids.length} `${statLabel(String(effect.stat_attribute_id))} +${effect.value}`).join(', ')}>{Number(weapon.is_exotic) ? '6' : '5'} SLOTS{Number(weapon.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}
+
{weapon.name}{#if weapon.pending}PROPOSED BY {weapon.proposerName}{:else}{Number(weapon.is_exotic) ? 'EXOTIC · ' : ''}{weapon.damage_type.toUpperCase()} DAMAGE · {weapon.baseBonuses.length} BASE BONUSES{/if}
{#if Number(weapon.is_exotic)}{/if}{weapon.category} · T{weapon.native_tier}{weapon.rpm} RPM · {weapon.magazine} MAG · {weapon.reload_speed} RELOAD{weapon.tiers.length}/{3 - Number(weapon.native_tier)} CONFIGURED{weapon.talent1Ids.length} / {weapon.talent2Ids.length} `${statLabel(String(effect.stat_attribute_id))} +${effect.value}`).join(', ')}>{Number(weapon.is_exotic) ? '6' : '5'} SLOTS{weapon.pending ? 'PENDING' : Number(weapon.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}{#if weapon.pending}{:else}{/if}
{:else}
NO WEAPONS REGISTEREDAdd active weapon talents and attributes, then create the first armament definition.
{/each}