diff --git a/patch-notes/2026-08-13-video-clears-and-build-goals.md b/patch-notes/2026-08-13-video-clears-and-build-goals.md new file mode 100644 index 0000000..5b199df --- /dev/null +++ b/patch-notes/2026-08-13-video-clears-and-build-goals.md @@ -0,0 +1,25 @@ +--- +title: Merry Christmas +date: 2026-08-13 +summary: Add video links for Stygian recommendations, build goals for simmed characters, alter Stygian algorithm (read full notes) +--- + +## Stygian clear videos + +Inside /tools/stygian, added a button to display clears using the teams suggested - of course some teams will not have clears. + +## Stygian algorithm improvement (?) + +Inside /tools/stigian, added a dropdown to select different algorithms for recommending Stygian teams. + +Usage Rate: the previous algorithm, used usage rate to rank teams and solutions + +Video Clears C0R0: algorithm that only suggests teams with C0R0 recorded clears (restrictive due to sample size) + +Hybrid (New): combines usage rate and video clears - prioritizes solutions with video clears while also using usage rate. + +Testing using Hybrid by default to see how effective it ends up being. Feedback appreciated. + +## Build goals + +Inside /characters/{name}, added stat goals for most characters implemented in gcsim. Updated automatically as more characters get implemented. diff --git a/src/lib/app/race-abort.ts b/src/lib/app/race-abort.ts new file mode 100644 index 0000000..9feb47c --- /dev/null +++ b/src/lib/app/race-abort.ts @@ -0,0 +1,28 @@ +/** Reject when `signal` aborts without cancelling `promise`. */ +export function raceAbort( + promise: Promise, + signal?: AbortSignal, +): Promise { + if (!signal) return promise; + if (signal.aborted) { + return Promise.reject( + signal.reason ?? new DOMException("Aborted", "AbortError"), + ); + } + return new Promise((resolve, reject) => { + const onAbort = () => { + reject(signal.reason ?? new DOMException("Aborted", "AbortError")); + }; + signal.addEventListener("abort", onAbort, { once: true }); + promise.then( + (value) => { + signal.removeEventListener("abort", onAbort); + resolve(value); + }, + (err) => { + signal.removeEventListener("abort", onAbort); + reject(err); + }, + ); + }); +} diff --git a/src/lib/app/stygian-cheap-clears.ts b/src/lib/app/stygian-cheap-clears.ts new file mode 100644 index 0000000..0e4e8fa --- /dev/null +++ b/src/lib/app/stygian-cheap-clears.ts @@ -0,0 +1,108 @@ +/** + * Client fetch + cache for experimental Stygian cost-capped clears. + * Roster-keyed: owned teams × Fearless clears with cost ≤ maxCost, by time. + */ + +import type { + CharacterOwned, + StygianCheapClearRow, + StygianCheapClearsPayload, + StygianClearDifficulty, +} from "$lib/definitions"; +import { + STYGIAN_CHEAP_CLEARS_DEFAULT_MAX_COST, + STYGIAN_CHEAP_CLEARS_DIFFICULTY, +} from "$lib/definitions"; +import { raceAbort } from "$lib/app/race-abort"; +import { ownedNameIds } from "$lib/utils"; + +const API_URL = "/api/stygian-cheap-clears"; +const FETCH_TIMEOUT_MS = 15_000; + +type CacheEntry = { + rows: StygianCheapClearRow[]; +}; + +const cache = new Map(); +const inflight = new Map>(); + +function rosterKey(characters: string[]): string { + return JSON.stringify([...characters].sort()); +} + +function cacheKey( + characters: string[], + stygianVersion: number, + enemyIds: number[], + difficulty: StygianClearDifficulty, + maxCost: number, +): string { + const enemies = [...enemyIds].sort((a, b) => a - b).join(","); + return `${stygianVersion}:${difficulty}:c${maxCost}:${enemies}:${rosterKey(characters)}`; +} + +/** + * Ensure cost-capped clear rows for this roster × board are cached. + * Returns the rows (empty if none). + */ +export async function ensureCheapClears(opts: { + owned: CharacterOwned[]; + stygianVersion: number; + enemyIds: number[]; + difficulty?: StygianClearDifficulty; + maxCost?: number; + signal?: AbortSignal; +}): Promise { + const characters = [...ownedNameIds(opts.owned)]; + const difficulty = opts.difficulty ?? STYGIAN_CHEAP_CLEARS_DIFFICULTY; + const maxCost = opts.maxCost ?? STYGIAN_CHEAP_CLEARS_DEFAULT_MAX_COST; + const enemyIds = opts.enemyIds.filter((id) => Number.isFinite(id) && id > 0); + if (characters.length === 0 || enemyIds.length === 0) return []; + + const key = cacheKey( + characters, + opts.stygianVersion, + enemyIds, + difficulty, + maxCost, + ); + const hit = cache.get(key); + if (hit) return raceAbort(Promise.resolve(hit.rows), opts.signal); + + let fetchPromise = inflight.get(key); + if (!fetchPromise) { + fetchPromise = (async () => { + const timeout = AbortSignal.timeout(FETCH_TIMEOUT_MS); + const res = await fetch(API_URL, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + characters, + stygianVersion: opts.stygianVersion, + enemyIds, + difficulty, + maxCost, + }), + signal: timeout, + }); + if (!res.ok) { + throw new Error(`stygian-cheap-clears HTTP ${res.status}`); + } + const payload = (await res.json()) as StygianCheapClearsPayload; + const rows = payload.rows ?? []; + cache.set(key, { rows }); + return rows; + })(); + inflight.set(key, fetchPromise); + void fetchPromise + .finally(() => { + if (inflight.get(key) === fetchPromise) inflight.delete(key); + }) + .then( + () => {}, + () => {}, + ); + } + + return raceAbort(fetchPromise, opts.signal); +} diff --git a/src/lib/app/stygian-clear-videos.ts b/src/lib/app/stygian-clear-videos.ts new file mode 100644 index 0000000..70eee75 --- /dev/null +++ b/src/lib/app/stygian-clear-videos.ts @@ -0,0 +1,159 @@ +/** + * Client fetch + cache for Stygian clear videos (team_key × enemy_id). + * Called after the solver for the visible solution's three seats. + */ + +import type { + StygianClearVideo, + StygianClearVideoPair, + StygianClearVideosPayload, +} from "$lib/definitions"; +import { raceAbort } from "$lib/app/race-abort"; + +const API_URL = "/api/stygian-clear-videos"; +const FETCH_TIMEOUT_MS = 15_000; +/** Keep in sync with MAX_TEAM_ENEMY_PAIRS in request-validation. */ +const MAX_PAIRS_PER_REQUEST = 12; + +function pairKey(teamKey: string, enemyId: number): string { + return `${teamKey}|${enemyId}`; +} + +/** Cached clears keyed by team_key|enemy_id (empty array = known miss). */ +const cache = new Map(); +const inflight = new Map>(); + +export function clearVideosCacheKey(teamKey: string, enemyId: number): string { + return pairKey(teamKey, enemyId); +} + +/** YouTube video id from a watch / youtu.be / Shorts URL, or null. */ +function youtubeVideoId(videoUrl: string): string | null { + try { + const u = new URL(videoUrl); + const host = u.hostname.replace(/^www\./, "").toLowerCase(); + let id: string | null = null; + if (host === "youtu.be") { + id = u.pathname.replace(/^\/+|\/+$/g, "").split("/")[0] || null; + } else if (host === "youtube.com" || host === "m.youtube.com") { + id = u.searchParams.get("v"); + if (!id) { + const shorts = u.pathname.match(/^\/shorts\/([^/]+)/); + id = shorts?.[1] ?? null; + } + } + if (!id || !/^[\w-]{6,}$/.test(id)) return null; + return id; + } catch { + return null; + } +} + +/** + * YouTube thumbnail URL, or null for non-YouTube / unparseable links. + * Prefers maxres (1280×720, true 16:9); callers should fall back on error — + * maxres is missing for some older videos. + */ +export function youtubeThumbnailUrl(videoUrl: string): string | null { + const id = youtubeVideoId(videoUrl); + if (!id) return null; + return `https://i.ytimg.com/vi/${id}/maxresdefault.jpg`; +} + +/** Reliable HQ fallback when maxres 404s (480×360, often letterboxed). */ +export function youtubeThumbnailFallbackUrl(videoUrl: string): string | null { + const id = youtubeVideoId(videoUrl); + if (!id) return null; + return `https://i.ytimg.com/vi/${id}/hqdefault.jpg`; +} + +/** Clears already in cache for this pair (including empty). */ +export function getClearVideosCached( + teamKey: string, + enemyId: number, +): StygianClearVideo[] | undefined { + return cache.get(pairKey(teamKey, enemyId)); +} + +/** + * Ensure clear videos for these pairs are cached. Only requests missing keys. + * Returns a map of pairKey → clears (empty arrays for misses). + */ +export async function ensureClearVideos( + pairs: StygianClearVideoPair[], + signal?: AbortSignal, +): Promise> { + const unique = new Map(); + for (const p of pairs) { + if (!p.team_key || !Number.isFinite(p.enemy_id) || p.enemy_id <= 0) continue; + unique.set(pairKey(p.team_key, p.enemy_id), p); + } + + const missing: StygianClearVideoPair[] = []; + const waits: Promise[] = []; + for (const [key, pair] of unique) { + if (cache.has(key)) continue; + const pending = inflight.get(key); + if (pending) { + waits.push(pending); + } else { + missing.push(pair); + } + } + + for (let i = 0; i < missing.length; i += MAX_PAIRS_PER_REQUEST) { + const chunk = missing.slice(i, i + MAX_PAIRS_PER_REQUEST); + const fetchPromise = (async () => { + const timeout = AbortSignal.timeout(FETCH_TIMEOUT_MS); + const res = await fetch(API_URL, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ pairs: chunk }), + signal: timeout, + }); + if (!res.ok) { + throw new Error(`stygian-clear-videos HTTP ${res.status}`); + } + const payload = (await res.json()) as StygianClearVideosPayload; + const byPair = new Map(); + for (const p of chunk) { + byPair.set(pairKey(p.team_key, p.enemy_id), []); + } + for (const row of payload.clears ?? []) { + const key = pairKey(row.team_key, row.enemy_id); + const list = byPair.get(key); + if (list) list.push(row); + else byPair.set(key, [row]); + } + for (const [key, list] of byPair) { + cache.set(key, list); + } + })(); + + for (const p of chunk) { + inflight.set(pairKey(p.team_key, p.enemy_id), fetchPromise); + } + void fetchPromise + .finally(() => { + for (const p of chunk) { + const key = pairKey(p.team_key, p.enemy_id); + if (inflight.get(key) === fetchPromise) inflight.delete(key); + } + }) + .then( + () => {}, + () => {}, + ); + waits.push(fetchPromise); + } + + if (waits.length > 0) { + await raceAbort(Promise.all(waits), signal); + } + + const out = new Map(); + for (const key of unique.keys()) { + out.set(key, cache.get(key) ?? []); + } + return raceAbort(Promise.resolve(out), signal); +} diff --git a/src/lib/build-stats.ts b/src/lib/build-stats.ts index 99e093b..a9eb0f7 100644 --- a/src/lib/build-stats.ts +++ b/src/lib/build-stats.ts @@ -69,10 +69,68 @@ export const SUBSTAT_ROLL_VALUE: Record = { /** Artifact substat keys only (excludes elemental DMG / heal / physical mains). */ export const ARTIFACT_SUBSTAT_KEYS = new Set(Object.keys(SUBSTAT_ROLL_VALUE)); +/** + * Max rolls of one substat on one piece for goal display. OptimFull ignores + * piece mains and can over-allocate — UI clamps to 3 × eligible pieces + * (e.g. EM/EM/EM → 6 EM on flower+plume; ATK/ATK/CR → 12 CR max). + */ +export const MAX_SUBSTAT_ROLLS_PER_PIECE = 3; + export function isArtifactSubstatKey(key: string): boolean { return ARTIFACT_SUBSTAT_KEYS.has(key); } +export type MainStatSlots = { + sands?: string; + goblet?: string; + circlet?: string; +}; + +/** + * Pieces that can hold ``stat`` as a substat given flower/plume mains and the + * three selectable mains (EM/EM/EM → only flower + plume for EM). + */ +export function eligibleSubstatPieceCount( + stat: string, + mainStats: MainStatSlots | null | undefined, +): number { + let n = 0; + // Flower main is flat HP; plume main is flat ATK. + if (stat !== "hp") n += 1; + if (stat !== "atk") n += 1; + for (const slot of ["sands", "goblet", "circlet"] as const) { + if (mainStats?.[slot] !== stat) n += 1; + } + return n; +} + +export function maxSubstatRolls( + stat: string, + mainStats: MainStatSlots | null | undefined, +): number { + return MAX_SUBSTAT_ROLLS_PER_PIECE * eligibleSubstatPieceCount(stat, mainStats); +} + +/** Cap each OptimFull roll count to what artifact pieces can actually hold. */ +export function clampSubstatRolls( + rolls: Record | null | undefined, + mainStats: MainStatSlots | null | undefined, +): Record { + if (!rolls) return {}; + const out: Record = {}; + for (const [key, raw] of Object.entries(rolls)) { + if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= 0) continue; + const n = Math.floor(raw); + if (!ARTIFACT_SUBSTAT_KEYS.has(key)) { + out[key] = n; + continue; + } + const capped = Math.min(n, maxSubstatRolls(key, mainStats)); + if (capped > 0) out[key] = capped; + } + return out; +} + const WEAPON_PROP_TO_GOOD: Record = { FIGHT_PROP_HP_PERCENT: "hp_", FIGHT_PROP_ATTACK_PERCENT: "atk_", @@ -186,7 +244,9 @@ export function computeBuildSheetStats( else if (key.endsWith("_dmg_")) add(dmgBonus, key, v); } - for (const [key, count] of Object.entries(build.substat_rolls ?? {})) { + for (const [key, count] of Object.entries( + clampSubstatRolls(build.substat_rolls, build.main_stats), + )) { const per = SUBSTAT_ROLL_VALUE[key]; if (per == null || !count) continue; const v = per * count; diff --git a/src/lib/character-builds.test.ts b/src/lib/character-builds.test.ts index 55eabcf..cb1c019 100644 --- a/src/lib/character-builds.test.ts +++ b/src/lib/character-builds.test.ts @@ -8,6 +8,10 @@ import { describe, it } from "node:test"; import { constellationImpactRows, constellationPrioritySection, + characterBuildFromExample, + exampleHasHighConfig, + exampleRelevantGoodKeys, + exampleUsesFavonius, formatReactionFingerprint, formatReactionName, levelImportanceFromBuilds, @@ -22,6 +26,7 @@ import { useGuideSection, } from "./character-builds.ts"; import type { + CharacterBuildExample, CharacterIndex, CharacterTalentImportance, } from "./types/investment.ts"; @@ -679,3 +684,182 @@ describe("reaction helpers", () => { ); }); }); + +describe("exampleRelevantGoodKeys display rules", () => { + const base = { + team_key: "t", + team_name: "T", + characters: ["Xilonen", "A", "B", "C"], + state_key: "Xilonen~C0~FavoniusSword~R5", + reactions: { + rps: null, + metric: "damage" as const, + list: [], + primary: null, + fingerprint: null, + }, + artifact_pct_gain: 0.4, + key: "Xilonen", + cons: 0, + level: 90, + talents: { auto: 1, skill: 9, burst: 9 }, + weapon: { key: "FavoniusSword", refinement: 5, level: 90 }, + set: { key: "ScrollsOfTheHearthfire", count: 4 }, + main_stats: { + sands: "enerRech_", + goblet: "geo_dmg_", + circlet: "critRate_", + }, + substat_rolls: { enerRech_: 12, critRate_: 10, critDMG_: 4 }, + substat_rolls_liquid: { enerRech_: 10, critRate_: 8, critDMG_: 2 }, + }; + + it("mid-only on Fav → ER + CR", () => { + const example: CharacterBuildExample = { + ...base, + invest: "mid", + }; + assert.deepEqual( + [...exampleRelevantGoodKeys(example)].sort(), + ["critRate_", "enerRech_"], + ); + }); + + it("mid-only without Fav → ER only (no CR)", () => { + const example: CharacterBuildExample = { + ...base, + invest: "mid", + state_key: "Mona~C2~ThrillingTalesOfDragonSlayers~R5", + weapon: { + key: "ThrillingTalesOfDragonSlayers", + refinement: 5, + level: 90, + }, + }; + assert.deepEqual([...exampleRelevantGoodKeys(example)].sort(), [ + "enerRech_", + ]); + }); + + it("mid-only with uniform mains → ER + that main", () => { + const example: CharacterBuildExample = { + ...base, + invest: "mid", + state_key: "Sucrose~C6~SacrificialFragments~R5", + weapon: { + key: "SacrificialFragments", + refinement: 5, + level: 90, + }, + main_stats: { + sands: "eleMas", + goblet: "eleMas", + circlet: "eleMas", + }, + }; + assert.deepEqual([...exampleRelevantGoodKeys(example)].sort(), [ + "eleMas", + "enerRech_", + ]); + }); + + it("high invest → mains + high liquids (not mid leftovers)", () => { + const example: CharacterBuildExample = { + ...base, + key: "RaidenShogun", + invest: "high", + main_stats: { + sands: "eleMas", + goblet: "eleMas", + circlet: "eleMas", + }, + substat_rolls_liquid: { atk_: 10, critDMG_: 2 }, + high_substat_rolls: { eleMas: 16, enerRech_: 16 }, + high_substat_rolls_liquid: { eleMas: 15, enerRech_: 15 }, + }; + const keys = exampleRelevantGoodKeys(example, "high"); + assert.equal(keys.has("eleMas"), true); + assert.equal(keys.has("enerRech_"), true); + assert.equal(keys.has("atk_"), false); + assert.equal(keys.has("critDMG_"), false); + }); + + it("mid invest keeps mid sheet even when high_substat_rolls is present", () => { + const example: CharacterBuildExample = { + ...base, + invest: "mid", + substat_rolls: { enerRech_: 12, critRate_: 10, critDMG_: 4 }, + substat_rolls_liquid: { enerRech_: 10, critRate_: 8, critDMG_: 2 }, + high_substat_rolls: { eleMas: 16, enerRech_: 16 }, + high_substat_rolls_liquid: { eleMas: 15, enerRech_: 15 }, + }; + assert.equal(exampleHasHighConfig(example), false); + assert.deepEqual([...exampleRelevantGoodKeys(example, "mid")].sort(), [ + "critRate_", + "enerRech_", + ]); + assert.deepEqual([...exampleRelevantGoodKeys(example, "high")].sort(), [ + "critRate_", + "enerRech_", + ]); + const build = characterBuildFromExample(example, "mid"); + assert.equal(build.substat_rolls.enerRech_, 12); + assert.equal(build.substat_rolls.critRate_, 10); + assert.equal(build.substat_rolls_liquid.enerRech_, 10); + assert.equal(build.substat_rolls_liquid.critRate_, 8); + assert.equal(build.substat_rolls.eleMas, undefined); + assert.equal(build.substat_rolls_liquid.eleMas, undefined); + }); + + it("clamps EM liquid to flower+plume when mains are EM/EM/EM", () => { + const example: CharacterBuildExample = { + ...base, + key: "RaidenShogun", + invest: "high", + main_stats: { + sands: "eleMas", + goblet: "eleMas", + circlet: "eleMas", + }, + high_substat_rolls: { eleMas: 16, enerRech_: 16 }, + high_substat_rolls_liquid: { eleMas: 15, enerRech_: 15 }, + }; + const build = characterBuildFromExample(example, "high"); + assert.equal(build.substat_rolls_liquid.eleMas, 6); + assert.equal(build.substat_rolls.eleMas, 6); + assert.equal(build.substat_rolls_liquid.enerRech_, 15); + }); + + it("clamps CR to 12 when circlet is CR (4 pieces × 3)", () => { + const example: CharacterBuildExample = { + ...base, + key: "RaidenShogun", + invest: "high", + main_stats: { + sands: "atk_", + goblet: "atk_", + circlet: "critRate_", + }, + high_substat_rolls: { critRate_: 18, critDMG_: 19 }, + high_substat_rolls_liquid: { critRate_: 17, critDMG_: 18 }, + }; + const build = characterBuildFromExample(example, "high"); + assert.equal(build.substat_rolls_liquid.critRate_, 12); + assert.equal(build.substat_rolls.critRate_, 12); + assert.equal(build.substat_rolls_liquid.critDMG_, 15); + }); + + it("maps 5pc→4pc and drops 1pc set2", () => { + const example: CharacterBuildExample = { + ...base, + invest: "mid", + set: { key: "EmblemOfSeveredFate", count: 5 }, + set2: "NoblesseOblige", + set2_count: 1, + }; + const build = characterBuildFromExample(example, "mid"); + assert.equal(build.set.count, 4); + assert.equal(build.set2, undefined); + assert.equal(build.set2_count, undefined); + }); +}); diff --git a/src/lib/character-builds.ts b/src/lib/character-builds.ts index 45eedc3..33cdd00 100644 --- a/src/lib/character-builds.ts +++ b/src/lib/character-builds.ts @@ -4,7 +4,11 @@ * Pure helpers so the character page stays props → $derived → markup. */ -import { isArtifactSubstatKey } from "$lib/build-stats"; +import { + clampSubstatRolls, + computeBuildSheetStats, + isArtifactSubstatKey, +} from "$lib/build-stats"; import { translateStatKey } from "$lib/utils"; import { CONSTELLATION_UPGRADE, @@ -19,6 +23,8 @@ import { type UpgradeTier, } from "$lib/upgrade-priority"; import type { + CharacterBuild, + CharacterBuildExample, CharacterConsGain, CharacterGuidePriority, CharacterIndex, @@ -536,3 +542,253 @@ export function formatReactionFingerprint( .map((part) => formatReactionName(part.trim())) .join(" + "); } + +export type LiquidRollChip = { key: string; rolls: number }; + +/** Non-zero liquid rolls, highest first (for build-example chips). */ +export function liquidRollChips( + rolls: Record | null | undefined, + limit = 6, +): LiquidRollChip[] { + if (!rolls) return []; + return Object.entries(rolls) + .filter(([, n]) => typeof n === "number" && n > 0) + .map(([key, n]) => ({ key, rolls: n })) + .sort((a, b) => b.rolls - a.rolls || a.key.localeCompare(b.key)) + .slice(0, limit); +} + +export type ExampleSheetRow = { + /** Sheet / format key (e.g. ``critRate``, ``pyro_dmg_``). */ + key: string; + /** GOOD key for ``statIconUrl``. */ + iconKey: string; + label: string; + value: number; +}; + +export type ExampleRollTier = "mid" | "high"; + +function sheetIconKey(stat: string): string { + if (stat === "critRate") return "critRate_"; + if (stat === "critDMG") return "critDMG_"; + if (stat === "enerRech") return "enerRech_"; + if (stat === "heal") return "heal_"; + return stat; +} + +/** True when the example carries a distinct high-invest roll sheet. */ +export function exampleHasHighConfig(example: CharacterBuildExample): boolean { + if (example.invest !== "high") return false; + const rolls = example.high_substat_rolls; + if (!rolls || typeof rolls !== "object") return false; + return Object.keys(rolls).length > 0; +} + +/** True when the example's baseline weapon is a Favonius piece. */ +export function exampleUsesFavonius(example: CharacterBuildExample): boolean { + const key = example.weapon?.key; + return typeof key === "string" && key.toLowerCase().startsWith("favonius"); +} + +/** + * Which sheet lines to show for a build example. + * + * - Mid / negligible: baseline **ER**, plus **CR** only when that team's + * baseline weapon is Fav; if sands / goblet / circlet share one main, + * include that main too + * - High invest (`tier === "high"` with high rolls): **mains + high OptimFull + * liquids** (not mid leftover rolls) + */ +export function exampleRelevantGoodKeys( + example: CharacterBuildExample, + tier: ExampleRollTier = "mid", +): Set { + if (tier !== "high" || !exampleHasHighConfig(example)) { + const keys = new Set(["enerRech_"]); + if (exampleUsesFavonius(example)) keys.add("critRate_"); + const uniformMain = uniformMainStatKey(example); + if (uniformMain) keys.add(uniformMain); + return keys; + } + const keys = new Set(); + for (const slot of MAIN_STAT_SLOTS) { + const k = example.main_stats?.[slot.key]; + if (typeof k === "string" && k) keys.add(k); + } + const highLiquid = clampSubstatRolls( + example.high_substat_rolls_liquid, + example.main_stats, + ); + for (const [k, n] of Object.entries(highLiquid)) { + if (n > 0) keys.add(k); + } + return keys; +} + +/** Shared sands/goblet/circlet main, or null when they differ / are missing. */ +function uniformMainStatKey( + example: CharacterBuildExample, +): string | null { + const mains = MAIN_STAT_SLOTS.map((slot) => example.main_stats?.[slot.key]); + const first = mains[0]; + if (typeof first !== "string" || !first) return null; + return mains.every((m) => m === first) ? first : null; +} + +/** + * Numerical sheet lines for mains + assigned substats only (not the full + * default sheet). Flat/percent pairs collapse to one total (ATK, HP, DEF). + */ +export function exampleRelevantSheetRows( + example: CharacterBuildExample, + tier: ExampleRollTier = "mid", +): ExampleSheetRow[] { + const relevant = exampleRelevantGoodKeys(example, tier); + if (relevant.size === 0) return []; + + const sheet = computeBuildSheetStats( + characterBuildFromExample(example, tier), + ); + if (!sheet) return []; + + const rows: ExampleSheetRow[] = []; + const push = (key: string, label: string, value: number) => { + rows.push({ key, iconKey: sheetIconKey(key), label, value }); + }; + + if (relevant.has("hp") || relevant.has("hp_")) { + push("hp", "HP", sheet.hp); + } + if (relevant.has("atk") || relevant.has("atk_")) { + push("atk", "ATK", sheet.atk); + } + if (relevant.has("def") || relevant.has("def_")) { + push("def", "DEF", sheet.def); + } + if (relevant.has("eleMas")) { + push("eleMas", "Elemental Mastery", sheet.eleMas); + } + if (relevant.has("critRate_")) { + push("critRate", "CRIT Rate", sheet.critRate); + } + if (relevant.has("critDMG_")) { + push("critDMG", "CRIT DMG", sheet.critDMG); + } + if (relevant.has("enerRech_")) { + push("enerRech", "Energy Recharge", sheet.enerRech); + } + if (relevant.has("heal_")) { + push("heal", translateStatKey("heal_"), sheet.heal); + } + + for (const [key, value] of Object.entries(sheet.dmgBonus) + .filter(([k, v]) => relevant.has(k) && v > 0) + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))) { + push(key, translateStatKey(key), value); + } + + return rows; +} + +function isCompleteBuildExample(example: CharacterBuildExample): boolean { + return ( + typeof example.key === "string" && + example.key.length > 0 && + typeof example.cons === "number" && + typeof example.level === "number" && + !!example.talents && + typeof example.talents.auto === "number" && + typeof example.talents.skill === "number" && + typeof example.talents.burst === "number" + ); +} + +/** Examples that carry a full CharacterBuild payload (skip stale CDN rows). */ +export function buildExamples( + builds: CharacterIndex | null | undefined, +): CharacterBuildExample[] { + const list = builds?.build_examples; + if (!Array.isArray(list)) return []; + return list.filter(isCompleteBuildExample); +} + +/** + * Party GOOD keys for an example. Prefer the stamped list; fall back to + * parsing ``state_key`` (``Char~C0~Weapon~R1__…``) for older CDN rows. + */ +export function exampleTeamKeys(example: CharacterBuildExample): string[] { + if (Array.isArray(example.characters) && example.characters.length > 0) { + return example.characters; + } + if (!example.state_key) return []; + return example.state_key + .split("__") + .map((part) => part.split("~")[0]?.trim() ?? "") + .filter(Boolean); +} + +/** + * Pulls-style split: featured character first, then up to ``mateSlots`` mates + * (null-padded so the strip stays fixed-width). + */ +export function exampleFeaturedAndMates( + keys: readonly string[], + featuredKey: string, + mateSlots = 3, +): { featured: string | null; mates: (string | null)[] } { + const featured = keys.find((k) => k === featuredKey) ?? keys[0] ?? null; + const rest = featured ? keys.filter((k) => k !== featured) : [...keys]; + const mates: (string | null)[] = []; + for (let i = 0; i < mateSlots; i++) { + mates.push(rest[i] ?? null); + } + return { featured, mates }; +} + +/** + * Sim configs sometimes stamp 5pc (on-set flower) or 3pc/1pc leftovers. + * Real bonuses are only 2pc / 4pc — map 5→4, 3→2; drop 1pc. + */ +export function normalizeSetPieceCount( + count: number | null | undefined, +): 2 | 4 | null { + if (count == null || !Number.isFinite(count)) return null; + const n = Math.trunc(count); + if (n >= 4) return 4; + if (n >= 2) return 2; + return null; +} + +/** Strip example metadata down to the shared InvestmentBuildCard shape. */ +export function characterBuildFromExample( + example: CharacterBuildExample, + tier: ExampleRollTier = "mid", +): CharacterBuild { + const useHigh = tier === "high" && exampleHasHighConfig(example); + const mains = example.main_stats; + const totals = useHigh + ? (example.high_substat_rolls ?? {}) + : example.substat_rolls; + const liquid = useHigh + ? (example.high_substat_rolls_liquid ?? {}) + : example.substat_rolls_liquid; + const setCount = normalizeSetPieceCount(example.set.count) ?? 4; + const set2Count = example.set2 + ? normalizeSetPieceCount(example.set2_count ?? 2) + : null; + return { + key: example.key, + cons: example.cons, + level: example.level, + talents: example.talents, + weapon: example.weapon, + set: { key: example.set.key, count: setCount }, + set2: set2Count != null ? example.set2 : undefined, + set2_count: set2Count ?? undefined, + main_stats: mains, + // OptimFull can over-allocate onto mains; clamp for sheet/goals display. + substat_rolls: clampSubstatRolls(totals, mains), + substat_rolls_liquid: clampSubstatRolls(liquid, mains), + }; +} diff --git a/src/lib/definitions.ts b/src/lib/definitions.ts index 7d12a6d..0d47e20 100644 --- a/src/lib/definitions.ts +++ b/src/lib/definitions.ts @@ -1,4 +1,4 @@ -import type { Tables, Database } from "$lib/types/database.types"; +import type { Tables, Database, Json } from "$lib/types/database.types"; export type Character = Tables<"characters">; export type CharacterOwned = Character & { isOwned: boolean }; @@ -114,4 +114,139 @@ export type StygianSchedule = { challengeName: string | null; } | null; +/** One verified clear video row (stygian.moe ingest). */ +export type StygianClearVideo = Pick< + Tables<"stygian_team_clear_videos">, + | "clear_key" + | "team_key" + | "enemy_id" + | "difficulty" + | "cost" + | "time_s" + | "video_url" + | "char_names" +>; + +export type StygianClearVideoPair = { + team_key: string; + enemy_id: number; +}; + +export type StygianClearVideosPayload = { + clears: StygianClearVideo[]; +}; + +/** One non-dominated clear on a team×enemy cost/time frontier. */ +export type StygianClearFrontierPoint = { + /** Scrape cost (stygian.moe). */ + c: number; + /** Clear time in seconds. */ + t: number; +}; + +/** Row fields needed to pick a clear under a scrape-cost limit. */ +export type StygianCheapClearFrontier = { + /** Raw RPC jsonb; normalize via team-cost helpers before reading points. */ + frontier: Json | null; +}; + +/** + * Owned team × boss clear stats (`p_max_cost` overload). + * `frontier` is Json from PostgREST; callers normalize via team-cost helpers. + */ +export type StygianCheapClearRow = StygianTeam & { + enemy_id: number; + min_cost: number | null; + frontier: Json | null; +}; + +export type StygianCheapClearsPayload = { + rows: StygianCheapClearRow[]; +}; + +/** Difficulty used for cost-capped video-clear seating. */ +export type StygianClearDifficulty = "Fearless" | "Dire"; + +export const STYGIAN_CHEAP_CLEARS_DIFFICULTY = "Fearless" as const satisfies StygianClearDifficulty; + +export const STYGIAN_CLEAR_DIFFICULTY_OPTIONS: ReadonlyArray<{ + value: StygianClearDifficulty; + label: string; +}> = [ + { value: "Fearless", label: "Fearless" }, + { value: "Dire", label: "Dire" }, +]; + +export function isStygianClearDifficulty( + value: unknown, +): value is StygianClearDifficulty { + return STYGIAN_CLEAR_DIFFICULTY_OPTIONS.some( + (option) => option.value === value, + ); +} + +/** Default max clear cost when filtering before time ranking. */ +export const STYGIAN_CHEAP_CLEARS_DEFAULT_MAX_COST = 0; + +/** + * Stygian board seating source. + * - yshelper: usage × affinity solver + * - hybrid: YSHelper boards, prefer those with more C0R0 clear seats + * - video: clear videos under the cost cap (scrape cost as labeled; dev) + * - video-c0r0: same, but only baseline (character floor + 0.5) + */ +export type StygianSolverMode = + | "yshelper" + | "hybrid" + | "video" + | "video-c0r0"; + +/** Modes shown on `/tools/stygian` (Fearless only). */ +export type StygianSolverModeRelease = Exclude; + +export const STYGIAN_SOLVER_MODE_OPTIONS: ReadonlyArray<{ + value: StygianSolverMode; + label: string; +}> = [ + { value: "yshelper", label: "YSHelper" }, + { value: "hybrid", label: "Hybrid" }, + { value: "video", label: "Video clears" }, + { value: "video-c0r0", label: "Video clears C0R0" }, +]; + +/** Production Stygian board dropdown. */ +export const STYGIAN_SOLVER_MODE_OPTIONS_RELEASE: ReadonlyArray<{ + value: StygianSolverModeRelease; + label: string; +}> = [ + { value: "hybrid", label: "Hybrid (New)" }, + { value: "yshelper", label: "Usage Rate" }, + { value: "video-c0r0", label: "Video Clears C0R0" }, +]; + +/** Default seating mode on `/tools/stygian`. */ +export const STYGIAN_SOLVER_MODE_DEFAULT = + "hybrid" as const satisfies StygianSolverModeRelease; + +export function isStygianSolverMode(value: unknown): value is StygianSolverMode { + return STYGIAN_SOLVER_MODE_OPTIONS.some((option) => option.value === value); +} + +export function isStygianSolverModeRelease( + value: unknown, +): value is StygianSolverModeRelease { + return STYGIAN_SOLVER_MODE_OPTIONS_RELEASE.some( + (option) => option.value === value, + ); +} + +/** Map stored prefs onto the production mode set (drops experimental `video`). */ +export function toStygianSolverModeRelease( + value: StygianSolverMode, +): StygianSolverModeRelease { + return isStygianSolverModeRelease(value) + ? value + : STYGIAN_SOLVER_MODE_DEFAULT; +} + export type { TierBoard, TierListEntry, TierListPayload } from "$lib/tierlist"; diff --git a/src/lib/e2e/fixtures.ts b/src/lib/e2e/fixtures.ts index 9816620..cee62b1 100644 --- a/src/lib/e2e/fixtures.ts +++ b/src/lib/e2e/fixtures.ts @@ -130,6 +130,24 @@ export const E2E_EMPTY_STYGIAN_ENEMIES: StygianEnemies = { bottom: null, }; +function e2eEnemy(id: number, name: string): Enemy { + return { + id, + enemy_name: name, + asset: `UI_MonsterIcon_Test_${id}`, + icon_path: null, + description: null, + created_at: "2024-01-01T00:00:00Z", + }; +} + +/** Board bosses so hybrid (default) seating has slot enemy ids. */ +export const E2E_STYGIAN_ENEMIES: StygianEnemies = { + top: e2eEnemy(101, "E2E Top Boss"), + middle: e2eEnemy(102, "E2E Middle Boss"), + bottom: e2eEnemy(103, "E2E Bottom Boss"), +}; + const E2E_ABYSS_VERSION: AbyssVersion = { version_number: 1, version_name: "test", @@ -204,7 +222,7 @@ export function e2eStaticPayload(): E2eStaticPayload { E2E_STYGIAN_TEAM_MIDDLE, E2E_STYGIAN_TEAM_BOTTOM, ], - stygianEnemies: E2E_EMPTY_STYGIAN_ENEMIES, + stygianEnemies: E2E_STYGIAN_ENEMIES, abyssEnemies: E2E_EMPTY_ABYSS_ENEMIES, stygianSchedule: null, }; @@ -328,14 +346,7 @@ export function e2eCharacterAnalyticsPayload( }; } -export const E2E_STYGIAN_ENEMY: Enemy = { - id: 1, - enemy_name: "Test Boss", - asset: "UI_MonsterIcon_Test", - icon_path: null, - description: null, - created_at: "2024-01-01T00:00:00Z", -}; +export const E2E_STYGIAN_ENEMY: Enemy = e2eEnemy(1, "Test Boss"); export function e2eStygianEnemyList(): StygianEnemyListItem[] { return [ diff --git a/src/lib/equipment-data.ts b/src/lib/equipment-data.ts index cef3e77..286cc2e 100644 --- a/src/lib/equipment-data.ts +++ b/src/lib/equipment-data.ts @@ -145,15 +145,25 @@ export function formatInvestmentCR( * Replace GOOD weapon keys in an investment sim label with display names. * When `characterByKey` is provided, character GOOD keys are replaced too * (longest-first so compound keys win). + * + * ``fiveStarWeaponsAs: "R1"`` — limited/signature 5★ keys become ``R1`` + * instead of the weapon name (vertical upgrades; non-sig 5★s live under + * ``owned`` and should keep ``"name"``). */ export function humanizeInvestmentLabel( label: string, characterByKey?: Map, + opts?: { fiveStarWeaponsAs?: "name" | "R1" }, ): string { if (!label) return label; + const fiveStarAs = opts?.fiveStarWeaponsAs ?? "name"; let out = label; for (const key of weaponKeysByLength) { if (!out.includes(key)) continue; + if (fiveStarAs === "R1" && isFiveStarWeapon(key)) { + out = out.split(key).join("R1"); + continue; + } const name = weaponByKey.get(key)?.name; if (!name) continue; out = out.split(key).join(name); diff --git a/src/lib/server/request-validation.test.ts b/src/lib/server/request-validation.test.ts index 15db167..4ceef68 100644 --- a/src/lib/server/request-validation.test.ts +++ b/src/lib/server/request-validation.test.ts @@ -1,14 +1,20 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { + MAX_ENEMY_IDS, MAX_NAME_ID_LENGTH, MAX_ROSTER_CHARACTERS, + MAX_TEAM_ENEMY_PAIRS, + MAX_TEAM_KEY_LENGTH, assertNoDbError, requireAnalyticsMode, requireCalculatorGoals, requireCharacterNameId, requireCharacterNameIds, requireEnemyId, + requireEnemyIds, + requireStygianClearDifficulty, + requireTeamEnemyPairs, requireFiniteInteger, requireIntegerInRange, requireJsonObject, @@ -312,4 +318,67 @@ describe("request validation", () => { assert.throws(() => requireEnemyId("abc"), isBadRequest); assert.throws(() => requireEnemyId(null), isBadRequest); }); + + it("requireEnemyIds validates and dedupes", () => { + assert.deepEqual(requireEnemyIds([1, 1, 2]), [1, 2]); + assert.throws(() => requireEnemyIds([]), isBadRequest); + assert.throws(() => requireEnemyIds([0]), isBadRequest); + assert.throws(() => requireEnemyIds("nope"), isBadRequest); + assert.throws( + () => requireEnemyIds(Array.from({ length: MAX_ENEMY_IDS + 1 }, (_, i) => i + 1)), + isBadRequest, + ); + }); + + it("requireStygianClearDifficulty defaults to Fearless", () => { + assert.equal(requireStygianClearDifficulty(undefined), "Fearless"); + assert.equal(requireStygianClearDifficulty(null), "Fearless"); + assert.equal(requireStygianClearDifficulty("Dire"), "Dire"); + assert.throws(() => requireStygianClearDifficulty("Hard"), isBadRequest); + }); + + it("requireTeamEnemyPairs validates and dedupes pairs", () => { + assert.deepEqual( + requireTeamEnemyPairs([ + { team_key: "abc", enemy_id: 1 }, + { team_key: "abc", enemy_id: 1 }, + { team_key: "def", enemy_id: 2 }, + ]), + [ + { team_key: "abc", enemy_id: 1 }, + { team_key: "def", enemy_id: 2 }, + ], + ); + assert.throws(() => requireTeamEnemyPairs([]), isBadRequest); + assert.throws(() => requireTeamEnemyPairs("nope"), isBadRequest); + assert.throws( + () => requireTeamEnemyPairs([{ team_key: "", enemy_id: 1 }]), + isBadRequest, + ); + assert.throws( + () => requireTeamEnemyPairs([{ team_key: "a", enemy_id: 0 }]), + isBadRequest, + ); + assert.throws( + () => requireTeamEnemyPairs([{ team_key: "a", enemy_id: 1, extra: true }]), + isBadRequest, + ); + assert.throws( + () => + requireTeamEnemyPairs([ + { team_key: "a".repeat(MAX_TEAM_KEY_LENGTH + 1), enemy_id: 1 }, + ]), + isBadRequest, + ); + assert.throws( + () => + requireTeamEnemyPairs( + Array.from({ length: MAX_TEAM_ENEMY_PAIRS + 1 }, (_, i) => ({ + team_key: `t${i}`, + enemy_id: i + 1, + })), + ), + isBadRequest, + ); + }); }); diff --git a/src/lib/server/request-validation.ts b/src/lib/server/request-validation.ts index ce72d7d..961d267 100644 --- a/src/lib/server/request-validation.ts +++ b/src/lib/server/request-validation.ts @@ -4,6 +4,11 @@ import { MAX_CALCULATOR_GOALS, MAX_GOAL_ID_LENGTH, } from "$lib/calculator-goals"; +import { + STYGIAN_CHEAP_CLEARS_DIFFICULTY, + isStygianClearDifficulty, + type StygianClearDifficulty, +} from "$lib/definitions"; import { MAX_ASCENSION, MAX_LEVEL, MAX_TALENT } from "$lib/upgrade-costs"; import type { CalculatorGoal } from "$lib/types/calculator-goals"; @@ -333,3 +338,106 @@ export function requireCharacterNameIds(value: unknown): string[] { } return value as string[]; } + +/** Soft cap — Stygian board needs 3; leave headroom for batching. */ +export const MAX_TEAM_ENEMY_PAIRS = 12; +/** Soft cap for cheap-clears enemy id lists (board is 3). */ +export const MAX_ENEMY_IDS = 8; +/** team_key is sha256 hex (64); allow a little headroom. */ +export const MAX_TEAM_KEY_LENGTH = 128; + +export type TeamEnemyPair = { team_key: string; enemy_id: number }; + +/** Validate `{ team_key, enemy_id }[]` for clear-video lookups. */ +export function requireTeamEnemyPairs(value: unknown): TeamEnemyPair[] { + if (!Array.isArray(value)) { + throw error(400, "pairs must be an array."); + } + if (value.length === 0) { + throw error(400, "pairs must not be empty."); + } + if (value.length > MAX_TEAM_ENEMY_PAIRS) { + throw error( + 400, + `pairs must have at most ${MAX_TEAM_ENEMY_PAIRS} entries.`, + ); + } + + const out: TeamEnemyPair[] = []; + const seen = new Set(); + for (const item of value) { + if (typeof item !== "object" || item === null || Array.isArray(item)) { + throw error(400, "each pair must be an object."); + } + const rec = item as Record; + const keys = Object.keys(rec); + if (keys.length !== 2 || !("team_key" in rec) || !("enemy_id" in rec)) { + throw error(400, "each pair must have exactly team_key and enemy_id."); + } + if (typeof rec.team_key !== "string" || rec.team_key.length === 0) { + throw error(400, "team_key must be a non-empty string."); + } + if (rec.team_key.length > MAX_TEAM_KEY_LENGTH) { + throw error( + 400, + `team_key must be at most ${MAX_TEAM_KEY_LENGTH} characters.`, + ); + } + const enemyId = requireFiniteInteger( + rec.enemy_id, + "enemy_id must be a finite integer.", + ); + if (!Number.isSafeInteger(enemyId) || enemyId <= 0) { + throw error(400, "enemy_id must be a positive integer."); + } + const key = `${rec.team_key}|${enemyId}`; + if (seen.has(key)) continue; + seen.add(key); + out.push({ team_key: rec.team_key, enemy_id: enemyId }); + } + return out; +} + +/** Validate a non-empty list of positive enemy ids (cheap-clears board). */ +export function requireEnemyIds(value: unknown): number[] { + if (!Array.isArray(value)) { + throw error(400, "enemyIds must be an array of positive integers."); + } + if (value.length === 0) { + throw error(400, "enemyIds must not be empty."); + } + if (value.length > MAX_ENEMY_IDS) { + throw error( + 400, + `enemyIds must have at most ${MAX_ENEMY_IDS} entries.`, + ); + } + const out: number[] = []; + const seen = new Set(); + for (const item of value) { + const id = requireFiniteInteger( + item, + "enemyIds must be an array of positive integers.", + ); + if (!Number.isSafeInteger(id) || id <= 0) { + throw error(400, "enemyIds must be an array of positive integers."); + } + if (seen.has(id)) continue; + seen.add(id); + out.push(id); + } + return out; +} + +/** Fearless (default) or Dire — matches stygian.moe ingest labels. */ +export function requireStygianClearDifficulty( + value: unknown, +): StygianClearDifficulty { + if (value === undefined || value === null) { + return STYGIAN_CHEAP_CLEARS_DIFFICULTY; + } + if (!isStygianClearDifficulty(value)) { + throw error(400, "difficulty must be Fearless or Dire."); + } + return value; +} diff --git a/src/lib/solver.test.ts b/src/lib/solver.test.ts index 8d02ac3..d71e7ff 100644 --- a/src/lib/solver.test.ts +++ b/src/lib/solver.test.ts @@ -5,7 +5,7 @@ */ import assert from "node:assert/strict"; import { describe, it } from "node:test"; -import type { AbyssTeam, StygianTeam } from "./definitions.ts"; +import type { AbyssTeam, StygianCheapClearRow, StygianTeam } from "./definitions.ts"; import { optimizeStygianSlotAssignments, scoreAssignments, @@ -13,6 +13,9 @@ import { solveAbyss, solveAbyssWithFallback, solveStygian, + solveStygianCheapClears, + solveStygianHybrid, + solveStygianWithFallback, } from "./solver.ts"; function abyssTeam( @@ -571,3 +574,451 @@ describe("solveAbyssWithFallback", () => { assert.ok(solutions[0].neededCharacters.includes("x")); }); }); + +describe("solveStygianWithFallback", () => { + it("keeps partial boards when no complete seating exists", () => { + // Every team is under MIN_SLOT_RATE on middle — no complete board possible. + const all = [ + stygianTeam({ + team_key: "soft-a", + members: ["a", "b", "c", "d"], + usage_rate: 90, + field_1_rate: 50, + field_2_rate: 50, + field_3_rate: 5, + }), + stygianTeam({ + team_key: "soft-b", + members: ["e", "f", "g", "h"], + usage_rate: 80, + field_1_rate: 50, + field_2_rate: 50, + field_3_rate: 4, + }), + stygianTeam({ + team_key: "soft-c", + members: ["i", "j", "k", "x"], + usage_rate: 70, + field_1_rate: 50, + field_2_rate: 50, + field_3_rate: 3, + }), + ]; + const ownedNames = new Set(["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k"]); + const solutions = solveStygianWithFallback([], all, ownedNames, 1); + assert.ok(solutions.length >= 1); + assert.equal(solutions[0].isFallback, true); + assert.ok(solutions[0].unfilled.length > 0); + assert.ok(solutions[0].unfilled.includes("middle")); + }); +}); + +describe("solveStygianCheapClears", () => { + /** Frontier with a single point at `fromCost` / `time`. */ + function cheapRow( + team: StygianTeam, + enemy_id: number, + fromCost: number, + time: number, + ): StygianCheapClearRow { + return { + ...team, + enemy_id, + min_cost: fromCost, + frontier: [{ c: fromCost, t: time }], + }; + } + + const slotEnemies = { top: 1, middle: 2, bottom: 3 } as const; + + it("minimizes total clear time across three seats", () => { + const fastTop = stygianTeam({ + team_key: "fast-top", + members: ["a", "b", "c", "d"], + usage_rate: 20, + field_1_rate: 80, + field_2_rate: 10, + field_3_rate: 10, + }); + const fastMid = stygianTeam({ + team_key: "fast-mid", + members: ["e", "f", "g", "h"], + usage_rate: 20, + field_1_rate: 10, + field_2_rate: 10, + field_3_rate: 80, + }); + const fastBot = stygianTeam({ + team_key: "fast-bot", + members: ["i", "j", "k", "l"], + usage_rate: 20, + field_1_rate: 10, + field_2_rate: 80, + field_3_rate: 10, + }); + const slowTop = stygianTeam({ + team_key: "slow-top", + members: ["m", "n", "o", "p"], + usage_rate: 90, + field_1_rate: 90, + field_2_rate: 10, + field_3_rate: 10, + }); + + const rows = [ + cheapRow(fastTop, 1, 0, 40), + cheapRow(fastMid, 2, 0, 50), + cheapRow(fastBot, 3, 0, 60), + cheapRow(slowTop, 1, 0, 200), + cheapRow(slowTop, 2, 0, 200), + cheapRow(slowTop, 3, 0, 200), + ]; + + const [best] = solveStygianCheapClears(rows, slotEnemies, 1); + assert.ok(best); + assert.equal(best.unfilled.length, 0); + assert.equal(best.score, 40 + 50 + 60); + const bySlot = Object.fromEntries( + best.assignments.map((a) => [a.slot, a.team.team_key]), + ); + assert.equal(bySlot.top, "fast-top"); + assert.equal(bySlot.middle, "fast-mid"); + assert.equal(bySlot.bottom, "fast-bot"); + }); + + it("returns empty when no complete cost board exists", () => { + const onlyTop = stygianTeam({ + team_key: "only", + members: ["a", "b", "c", "d"], + usage_rate: 50, + field_1_rate: 80, + field_2_rate: 10, + field_3_rate: 10, + }); + const rows = [cheapRow(onlyTop, 1, 0, 30)]; + assert.deepEqual(solveStygianCheapClears(rows, slotEnemies, 1), []); + }); + + it("uses the C0R0 floor + 0.5 even when a faster higher-cost clear exists", () => { + const characterByNameId = new Map([ + [ + "Mavuika", + { + game_id: 1, + name_id: "Mavuika", + name: "Mavuika", + rarity: 5, + }, + ], + [ + "Iansan", + { + game_id: 2, + name_id: "Iansan", + name: "Iansan", + rarity: 4, + }, + ], + [ + "Chevreuse", + { + game_id: 3, + name_id: "Chevreuse", + name: "Chevreuse", + rarity: 4, + }, + ], + [ + "Ororon", + { + game_id: 4, + name_id: "Ororon", + name: "Ororon", + rarity: 4, + }, + ], + ]); + + // Floor 1 → scrape ≤ 1.5. Faster cost-2 clear must not win. + const overload = stygianTeam({ + team_key: "mavuika-ol", + members: ["Mavuika", "Iansan", "Chevreuse", "Ororon"], + usage_rate: 50, + field_1_rate: 40, + field_2_rate: 30, + field_3_rate: 30, + }); + const mid = stygianTeam({ + team_key: "mid", + members: ["e", "f", "g", "h"], + usage_rate: 40, + field_1_rate: 10, + field_2_rate: 80, + field_3_rate: 10, + }); + const bot = stygianTeam({ + team_key: "bot", + members: ["i", "j", "k", "l"], + usage_rate: 40, + field_1_rate: 10, + field_2_rate: 10, + field_3_rate: 80, + }); + + const maskedOnly = { + ...overload, + enemy_id: 1, + min_cost: 2, + frontier: [{ c: 2, t: 20 }], + } satisfies StygianCheapClearRow; + + assert.deepEqual( + solveStygianCheapClears( + [ + maskedOnly, + { ...maskedOnly, enemy_id: 2 }, + { ...maskedOnly, enemy_id: 3 }, + cheapRow(mid, 2, 0, 50), + cheapRow(bot, 3, 0, 60), + ], + slotEnemies, + 1, + characterByNameId, + true, + 0, + ), + [], + ); + + const withC0r0 = { + ...overload, + enemy_id: 1, + min_cost: 1, + frontier: [ + { c: 1, t: 90 }, + { c: 2, t: 20 }, + ], + } satisfies StygianCheapClearRow; + + const [best] = solveStygianCheapClears( + [withC0r0, cheapRow(mid, 2, 0, 50), cheapRow(bot, 3, 0, 60)], + slotEnemies, + 1, + characterByNameId, + true, + 0, + ); + assert.ok(best); + assert.equal(best.unfilled.length, 0); + assert.equal( + best.assignments.find((a) => a.slot === "top")?.team.team_key, + "mavuika-ol", + ); + assert.equal(best.score, 90 + 50 + 60); + }); + + it("uses high-cost frontier points when maxCost is above baseline", () => { + const top = stygianTeam({ + team_key: "whale-top", + members: ["a", "b", "c", "d"], + usage_rate: 40, + field_1_rate: 80, + field_2_rate: 10, + field_3_rate: 10, + }); + const mid = stygianTeam({ + team_key: "whale-mid", + members: ["e", "f", "g", "h"], + usage_rate: 40, + field_1_rate: 10, + field_2_rate: 80, + field_3_rate: 10, + }); + const bot = stygianTeam({ + team_key: "whale-bot", + members: ["i", "j", "k", "l"], + usage_rate: 40, + field_1_rate: 10, + field_2_rate: 10, + field_3_rate: 80, + }); + + const row = ( + team: StygianTeam, + enemy_id: number, + time: number, + ): StygianCheapClearRow => ({ + ...team, + enemy_id, + min_cost: 6, + frontier: [{ c: 6, t: time }], + }); + + const [best] = solveStygianCheapClears( + [row(top, 1, 33), row(mid, 2, 44), row(bot, 3, 55)], + slotEnemies, + 1, + new Map(), + false, + 8, + ); + assert.ok(best); + assert.equal(best.score, 33 + 44 + 55); + }); +}); + +describe("solveStygianHybrid", () => { + const slotEnemies = { top: 1, middle: 2, bottom: 3 } as const; + + it("prefers boards with more C0R0-covered seats over higher usage", () => { + const highTop = stygianTeam({ + team_key: "high-top", + members: ["a", "b", "c", "d"], + usage_rate: 90, + field_1_rate: 80, + field_2_rate: 10, + field_3_rate: 10, + }); + const highMid = stygianTeam({ + team_key: "high-mid", + members: ["e", "f", "g", "h"], + usage_rate: 90, + field_1_rate: 10, + field_2_rate: 80, + field_3_rate: 10, + }); + const highBot = stygianTeam({ + team_key: "high-bot", + members: ["i", "j", "k", "l"], + usage_rate: 90, + field_1_rate: 10, + field_2_rate: 10, + field_3_rate: 80, + }); + const covTop = stygianTeam({ + team_key: "cov-top", + members: ["m", "n", "o", "p"], + usage_rate: 40, + field_1_rate: 80, + field_2_rate: 10, + field_3_rate: 10, + }); + const covMid = stygianTeam({ + team_key: "cov-mid", + members: ["q", "r", "s", "t"], + usage_rate: 40, + field_1_rate: 10, + field_2_rate: 80, + field_3_rate: 10, + }); + const covBot = stygianTeam({ + team_key: "cov-bot", + members: ["u", "v", "w", "x"], + usage_rate: 40, + field_1_rate: 10, + field_2_rate: 10, + field_3_rate: 80, + }); + + const owned = [highTop, highMid, highBot, covTop, covMid, covBot]; + const ownedNames = new Set(owned.flatMap((t) => t.members ?? [])); + const c0r0Pairs = new Set([ + "cov-top|1", + "cov-mid|2", + "cov-bot|3", + ]); + + const [best] = solveStygianHybrid( + owned, + owned, + ownedNames, + slotEnemies, + c0r0Pairs, + 1, + ); + assert.ok(best); + const keys = best.assignments.map((a) => a.team.team_key).sort(); + assert.deepEqual(keys, ["cov-bot", "cov-mid", "cov-top"]); + }); + + it("keeps the owned board first when fallbacks fill out the pool", () => { + const ownedTop = stygianTeam({ + team_key: "owned-top", + members: ["a", "b", "c", "d"], + usage_rate: 30, + field_1_rate: 80, + field_2_rate: 10, + field_3_rate: 10, + }); + const ownedMid = stygianTeam({ + team_key: "owned-mid", + members: ["e", "f", "g", "h"], + usage_rate: 30, + field_1_rate: 10, + field_2_rate: 80, + field_3_rate: 10, + }); + const ownedBot = stygianTeam({ + team_key: "owned-bot", + members: ["i", "j", "k", "l"], + usage_rate: 30, + field_1_rate: 10, + field_2_rate: 10, + field_3_rate: 80, + }); + const whaleTop = stygianTeam({ + team_key: "whale-top", + members: ["m", "n", "o", "p"], + usage_rate: 95, + field_1_rate: 90, + field_2_rate: 10, + field_3_rate: 10, + }); + const whaleMid = stygianTeam({ + team_key: "whale-mid", + members: ["q", "r", "s", "t"], + usage_rate: 95, + field_1_rate: 10, + field_2_rate: 90, + field_3_rate: 10, + }); + const whaleBot = stygianTeam({ + team_key: "whale-bot", + members: ["u", "v", "w", "x"], + usage_rate: 95, + field_1_rate: 10, + field_2_rate: 10, + field_3_rate: 90, + }); + + const owned = [ownedTop, ownedMid, ownedBot]; + const allTeams = [...owned, whaleTop, whaleMid, whaleBot]; + const ownedNames = new Set(owned.flatMap((team) => team.members ?? [])); + const c0r0Pairs = new Set([ + "owned-top|1", + "owned-mid|2", + "owned-bot|3", + "whale-top|1", + "whale-mid|2", + "whale-bot|3", + ]); + + const solutions = solveStygianHybrid( + owned, + allTeams, + ownedNames, + slotEnemies, + c0r0Pairs, + 3, + ); + assert.ok(solutions.length >= 2); + assert.equal(solutions[0]?.isFallback, false); + const ownedKeys = solutions[0]!.assignments + .map((a) => a.team.team_key) + .sort(); + assert.deepEqual(ownedKeys, ["owned-bot", "owned-mid", "owned-top"]); + for (const solution of solutions.slice(1)) { + assert.equal(solution.isFallback, true); + } + }); +}); diff --git a/src/lib/solver.ts b/src/lib/solver.ts index b7467e8..c5b7d6c 100644 --- a/src/lib/solver.ts +++ b/src/lib/solver.ts @@ -9,8 +9,14 @@ * the owned roster can't cover every slot. */ -import type { AbyssTeam, StygianTeam } from "$lib/definitions"; +import type { AbyssTeam, StygianCheapClearRow, StygianTeam } from "$lib/definitions"; import { teamSlotFieldRate } from "$lib/slot-fields"; +import { + clearTimeAtCap, + clearTimeAtCostCeiling, + floorTeamCost, +} from "$lib/team-cost"; +import type { CharacterMeta } from "$lib/tierlist"; // ---- Types ---------------------------------------------------------------- @@ -345,7 +351,7 @@ const MIN_ABYSS_USAGE_TOTAL = 0.001; /** Drop near-zero meta teams — shown as "0.0% usage" and not worth recommending. */ export const MIN_USAGE_RATE = 0.1; /** Bump when solver policy changes so page memos cannot reuse stale boards. */ -export const SOLVER_REVISION = 4; +export const SOLVER_REVISION = 7; function teamUsageRate(team: { usage_rate?: number | null }): number { const value = Number(team.usage_rate); @@ -534,6 +540,382 @@ export function solveStygianWithFallback( return [...completeOwned, ...supplemental].slice(0, count); } +/** How many hybrid forced-first boards to explore before ranking by C0R0 seats. */ +const HYBRID_POOL = 12; + +function hasC0r0Clear( + teamKey: string | null, + enemyId: number, + c0r0Pairs: ReadonlySet, +): boolean { + return teamKey != null && c0r0Pairs.has(`${teamKey}|${enemyId}`); +} + +function c0r0SeatCoverage( + solution: Solution, + slotEnemies: Record, + c0r0Pairs: ReadonlySet, +): number { + let covered = 0; + for (const assignment of solution.assignments) { + if ( + hasC0r0Clear( + assignment.team.team_key, + slotEnemies[assignment.slot], + c0r0Pairs, + ) + ) { + covered += 1; + } + } + return covered; +} + +/** + * Slot fill: prefer a C0R0 clear for that boss, then usage × affinity. + * Forced-first still walks the usage peak so meta boards stay in the pool. + */ +function greedyHybridPass( + teams: StygianTeam[], + slotEnemies: Record, + c0r0Pairs: ReadonlySet, + forcedFirst?: StygianTeam, +): Solution { + const placement = createPlacementContext( + STYGIAN_SLOT_ORDER, + ); + + const pairRank = ( + team: StygianTeam, + slot: StygianSlot, + ): { cover: number; usage: number } => { + if (!isSlotViable(team, slot)) { + return { cover: -1, usage: Number.NEGATIVE_INFINITY }; + } + return { + cover: hasC0r0Clear(team.team_key, slotEnemies[slot], c0r0Pairs) + ? 1 + : 0, + usage: placementScore(team, slot), + }; + }; + + const better = ( + a: { cover: number; usage: number }, + b: { cover: number; usage: number }, + ): boolean => a.cover > b.cover || (a.cover === b.cover && a.usage > b.usage); + + const pickBest = (): boolean => { + let bestTeam: StygianTeam | null = null; + let bestSlot: StygianSlot | null = null; + let best = { cover: -1, usage: Number.NEGATIVE_INFINITY }; + + for (const team of teams) { + if (!placement.canUseTeam(team)) continue; + for (const slot of STYGIAN_SLOT_ORDER) { + if (placement.isSlotFilled(slot)) continue; + const rank = pairRank(team, slot); + if (better(rank, best)) { + best = rank; + bestTeam = team; + bestSlot = slot; + } + } + } + + if (bestTeam == null || bestSlot == null || best.cover < 0) return false; + placement.commit(bestTeam, bestSlot); + return true; + }; + + if (forcedFirst) { + const open = STYGIAN_SLOT_ORDER.filter((s) => isSlotViable(forcedFirst, s)); + if (open.length > 0) { + const preferred = preferredStygianSlot(forcedFirst); + const slot = open.includes(preferred) + ? preferred + : open.reduce((bestSlot, slot) => + better(pairRank(forcedFirst, slot), pairRank(forcedFirst, bestSlot)) + ? slot + : bestSlot, + ); + placement.commit(forcedFirst, slot); + } + } + + while (!placement.isComplete) { + if (!pickBest()) break; + } + + // Do not run usage-only slot swaps — they undo C0R0 seat coverage. + const assignments = sortAssignments( + placement.assignments, + STYGIAN_SLOT_ORDER, + ).map((a) => ({ + ...a, + missingCharacters: [] as string[], + })); + + return { + assignments, + score: scoreAssignments(assignments), + unfilled: STYGIAN_SLOT_ORDER.filter( + (slot) => !assignments.some((a) => a.slot === slot), + ), + isFallback: false, + neededCharacters: [], + }; +} + +/** + * YSHelper-shaped seating that prefers seats with a baseline C0R0 clear for + * that boss, then ranks boards by how many of the three seats are covered. + */ +export function solveStygianHybrid( + ownedTeams: StygianTeam[], + allTeams: StygianTeam[], + ownedNames: Set, + slotEnemies: Record, + c0r0Pairs: ReadonlySet, + count = 3, +): Solution[] { + const validOwned = byUsageDesc(ownedTeams.filter(isRecommendableTeam)); + const candidates = validOwned.slice(0, CANDIDATE_DEPTH); + + const ownedSolutions = deduplicateSolutions( + candidates.map((forcedFirst) => + greedyHybridPass(validOwned, slotEnemies, c0r0Pairs, forcedFirst), + ), + ) + .map((solution) => ({ ...solution, isFallback: false })) + .filter((solution) => solution.unfilled.length === 0); + + let pool = ownedSolutions; + if (pool.length === 0) { + pool = buildMinMissingStygianSolutions(allTeams, ownedNames, HYBRID_POOL); + } else if (pool.length < count) { + const seen = new Set(pool.map((solution) => solutionTeamKey(solution))); + const supplemental = buildMinMissingStygianSolutions( + allTeams, + ownedNames, + HYBRID_POOL, + ).filter((solution) => { + if (solution.unfilled.length > 0) return false; + const key = solutionTeamKey(solution); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + pool = [...pool, ...supplemental]; + } + + return [...pool] + .sort((a, b) => { + if (a.isFallback !== b.isFallback) { + return Number(a.isFallback) - Number(b.isFallback); + } + const missingDiff = + a.neededCharacters.length - b.neededCharacters.length; + if (missingDiff !== 0) return missingDiff; + if (a.unfilled.length !== b.unfilled.length) { + return a.unfilled.length - b.unfilled.length; + } + const coverDiff = + c0r0SeatCoverage(b, slotEnemies, c0r0Pairs) - + c0r0SeatCoverage(a, slotEnemies, c0r0Pairs); + if (coverDiff !== 0) return coverDiff; + return b.score - a.score; + }) + .slice(0, count); +} + +/** + * Seat fully-owned teams to minimize Σ clear time under a cost ceiling. + * Rows carry a cost/time Pareto `frontier`. With `enforceCharacterFloor` + * (Video Clears C0R0), each team uses floor + 0.5; otherwise `maxCost` + 0.5. + */ +export function solveStygianCheapClears( + rows: StygianCheapClearRow[], + slotEnemies: Record, + count = 3, + characterByNameId: ReadonlyMap = new Map(), + enforceCharacterFloor = false, + maxCost = 0, +): Solution[] { + const timeByPair = new Map(); + const teamByKey = new Map(); + + for (const row of rows) { + if (!row.team_key) continue; + if (!isRecommendableTeam(row)) continue; + const time = enforceCharacterFloor + ? clearTimeAtCostCeiling( + row, + floorTeamCost(row.members ?? [], characterByNameId), + ) + : clearTimeAtCap(row, maxCost); + if (time == null) continue; + const key = `${row.team_key}|${row.enemy_id}`; + const prev = timeByPair.get(key); + if (prev == null || time < prev) { + timeByPair.set(key, time); + } + if (!teamByKey.has(row.team_key)) { + teamByKey.set(row.team_key, row); + } + } + + const timeFor = (teamKey: string, enemyId: number): number | null => { + const value = timeByPair.get(`${teamKey}|${enemyId}`); + return value == null ? null : value; + }; + + const canSeat = (team: StygianTeam, slot: StygianSlot): boolean => { + if (!team.team_key || !isSlotViable(team, slot)) return false; + return timeFor(team.team_key, slotEnemies[slot]) != null; + }; + + const seatTime = (team: StygianTeam, slot: StygianSlot): number => { + return timeFor(team.team_key!, slotEnemies[slot])!; + }; + + const teams = [...teamByKey.values()]; + if (teams.length === 0) return []; + + /** Fastest best-enemy time first — explore quick forced-first picks. */ + const byBestTime = [...teams].sort((a, b) => { + const best = (team: StygianTeam) => { + let min = Number.POSITIVE_INFINITY; + for (const slot of STYGIAN_SLOT_ORDER) { + if (!canSeat(team, slot)) continue; + min = Math.min(min, seatTime(team, slot)); + } + return min; + }; + return best(a) - best(b); + }); + + const candidates = byBestTime + .filter((team) => STYGIAN_SLOT_ORDER.some((slot) => canSeat(team, slot))) + .slice(0, CANDIDATE_DEPTH); + + function greedyFast( + forcedFirst?: StygianTeam, + ): Solution { + const placement = createPlacementContext( + STYGIAN_SLOT_ORDER, + ); + + const pickBest = (): boolean => { + let bestTeam: StygianTeam | null = null; + let bestSlot: StygianSlot | null = null; + let bestTime = Number.POSITIVE_INFINITY; + + for (const team of teams) { + if (!placement.canUseTeam(team)) continue; + for (const slot of STYGIAN_SLOT_ORDER) { + if (placement.isSlotFilled(slot)) continue; + if (!canSeat(team, slot)) continue; + const time = seatTime(team, slot); + if (time < bestTime) { + bestTime = time; + bestTeam = team; + bestSlot = slot; + } + } + } + + if (bestTeam == null || bestSlot == null) return false; + placement.commit(bestTeam, bestSlot); + return true; + }; + + if (forcedFirst) { + const open = STYGIAN_SLOT_ORDER.filter((s) => canSeat(forcedFirst, s)); + if (open.length > 0) { + const preferred = preferredStygianSlot(forcedFirst); + const slot = open.includes(preferred) + ? preferred + : open.reduce((best, s) => + seatTime(forcedFirst, s) < seatTime(forcedFirst, best) ? s : best, + ); + placement.commit(forcedFirst, slot); + } + } + + while (!placement.isComplete) { + if (!pickBest()) break; + } + + const optimized = optimizeCheapSlots( + placement.assignments, + canSeat, + seatTime, + ); + const assignments = sortAssignments(optimized, STYGIAN_SLOT_ORDER).map( + (a) => ({ + ...a, + missingCharacters: [] as string[], + }), + ); + const score = assignments.reduce( + (sum, a) => sum + seatTime(a.team, a.slot), + 0, + ); + + return { + assignments, + score, + unfilled: placement.unfilled, + isFallback: false, + neededCharacters: [], + }; + } + + const solutions = (candidates.length > 0 ? candidates : [undefined]).map( + (forced) => greedyFast(forced), + ); + + const complete = solutions.filter((sol) => sol.unfilled.length === 0); + if (complete.length === 0) return []; + + complete.sort((a, b) => a.score - b.score); + return deduplicateSolutions(complete).slice(0, count); +} + +/** Re-seat the same teams to minimize Σ clear time (enemy-specific). */ +function optimizeCheapSlots( + assignments: { team: StygianTeam; slot: StygianSlot }[], + canSeat: (team: StygianTeam, slot: StygianSlot) => boolean, + seatTime: (team: StygianTeam, slot: StygianSlot) => number, +): { team: StygianTeam; slot: StygianSlot }[] { + if (assignments.length <= 1) { + return assignments.map((a) => ({ ...a })); + } + + const teams = assignments.map((a) => a.team); + const slots = assignments.map((a) => a.slot); + let best = assignments.map((a) => ({ ...a })); + let bestTime = best.reduce((sum, a) => sum + seatTime(a.team, a.slot), 0); + + for (const perm of permutations(slots)) { + const candidate = teams.map((team, i) => ({ + team, + slot: perm[i]!, + })); + if (candidate.some((a) => !canSeat(a.team, a.slot))) continue; + const time = candidate.reduce( + (sum, a) => sum + seatTime(a.team, a.slot), + 0, + ); + if (time < bestTime) { + bestTime = time; + best = candidate; + } + } + return best; +} + // ---- Missing character helpers -------------------------------------------- function getMissingForTeam( @@ -647,6 +1029,9 @@ function buildMinMissingStygianSolutions( missing: getMissingForTeam(team, ownedNames), })); + const collected: Solution[] = []; + const seen = new Set(); + for (let budget = 0; budget <= 4; budget++) { const pool = teamsWithMissing .filter((entry) => entry.missing.length <= budget) @@ -658,18 +1043,27 @@ function buildMinMissingStygianSolutions( .map((entry) => entry.team); const solutions = solveStygian(pool, count); - if (solutions.length > 0 && solutions[0].unfilled.length === 0) { - return sortSolutionsByMissingThenScore( - solutions.map((solution) => - annotateSolutionMissing( - { ...solution, isFallback: true }, - ownedNames, - ), - ), + for (const solution of solutions) { + if (solution.unfilled.length > 0) continue; + const annotated = annotateSolutionMissing( + { ...solution, isFallback: true }, + ownedNames, ); + const key = solutionTeamKey(annotated); + if (seen.has(key)) continue; + seen.add(key); + collected.push(annotated); + if (collected.length >= count) { + return sortSolutionsByMissingThenScore(collected); + } } } + if (collected.length > 0) { + return sortSolutionsByMissingThenScore(collected); + } + + // No complete board at any missing budget — keep partials (Abyss-style). return sortSolutionsByMissingThenScore( solveStygian(allTeams, count).map((solution) => annotateSolutionMissing({ ...solution, isFallback: true }, ownedNames), diff --git a/src/lib/stores.ts b/src/lib/stores.ts index a421ad4..23ec3f6 100644 --- a/src/lib/stores.ts +++ b/src/lib/stores.ts @@ -22,6 +22,14 @@ import type { StygianEnemies, StygianSchedule, TierListPayload, + StygianSolverMode, + StygianClearDifficulty, +} from "$lib/definitions"; +import { + isStygianSolverMode, + isStygianClearDifficulty, + STYGIAN_CHEAP_CLEARS_DIFFICULTY, + STYGIAN_SOLVER_MODE_DEFAULT, } from "$lib/definitions"; import type { NearMissStygianTeam, @@ -103,6 +111,10 @@ export type DisplayPreferences = { colorTheme: ColorTheme; /** Overrides for individual CSS custom properties. Keyed without the `--` prefix. */ themeColors: Partial> | null; + /** Stygian board seating: usage solver vs Fearless video clears. */ + stygianSolverMode: StygianSolverMode; + /** Dire / Fearless filter for hybrid + video-clear seating. */ + stygianClearDifficulty: StygianClearDifficulty; }; const defaultDisplayPreferences: DisplayPreferences = { @@ -112,6 +124,8 @@ const defaultDisplayPreferences: DisplayPreferences = { backgroundApplyToHome: false, colorTheme: "dark", themeColors: null, + stygianSolverMode: STYGIAN_SOLVER_MODE_DEFAULT, + stygianClearDifficulty: STYGIAN_CHEAP_CLEARS_DIFFICULTY, }; export const displayPreferences = writable({ @@ -160,6 +174,18 @@ export function initDisplayPreferences(): void { .map(([k, v]) => [k, normalizeHexColor(v as string)]), ) as Partial>) : defaultDisplayPreferences.themeColors, + stygianSolverMode: isStygianSolverMode(parsed.stygianSolverMode) + ? parsed.stygianSolverMode + : // Migrate former experimental boolean → video clears (scrape-labeled). + (parsed as { stygianCheapClears?: unknown }).stygianCheapClears === + true + ? "video" + : defaultDisplayPreferences.stygianSolverMode, + stygianClearDifficulty: isStygianClearDifficulty( + parsed.stygianClearDifficulty, + ) + ? parsed.stygianClearDifficulty + : defaultDisplayPreferences.stygianClearDifficulty, }); } catch { displayPreferences.set({ ...defaultDisplayPreferences }); diff --git a/src/lib/team-cost.test.ts b/src/lib/team-cost.test.ts new file mode 100644 index 0000000..11e1096 --- /dev/null +++ b/src/lib/team-cost.test.ts @@ -0,0 +1,194 @@ +/** + * Unit tests for character-only team floor cost + clear frontiers. + * + * Run: pnpm exec tsx --test src/lib/team-cost.test.ts + */ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { CharacterMeta } from "./tierlist.ts"; +import { + baselineTeamCost, + bestClearUnderLimit, + c0r0ClearPairKeys, + clearCostAtCap, + clearTimeAtCap, + clearTimeAtCostCeiling, + floorTeamCost, + labeledCostWithinFloor, + normalizeClearFrontier, +} from "./team-cost.ts"; + +function meta(name_id: string, rarity: number): CharacterMeta { + return { + game_id: 0, + name_id, + name: name_id, + rarity, + }; +} + +const byId = new Map([ + ["Mavuika", meta("Mavuika", 5)], + ["Iansan", meta("Iansan", 4)], + ["Chevreuse", meta("Chevreuse", 4)], + ["Ororon", meta("Ororon", 4)], + ["Mualani", meta("Mualani", 5)], + ["Mona", meta("Mona", 5)], // standard banner + ["Qin", meta("Qin", 5)], // Jean +]); + +describe("floorTeamCost", () => { + it("counts limited 5★ only (Mavuika overload = 1)", () => { + assert.equal( + floorTeamCost(["Mavuika", "Iansan", "Chevreuse", "Ororon"], byId), + 1, + ); + }); + + it("sums multiple limited 5★s", () => { + assert.equal( + floorTeamCost(["Mavuika", "Mualani", "Iansan", "Ororon"], byId), + 2, + ); + }); + + it("ignores standard-banner 5★s", () => { + assert.equal(floorTeamCost(["Mona", "Qin", "Iansan", "Ororon"], byId), 0); + }); + + it("skips unknown ids", () => { + assert.equal(floorTeamCost(["Mavuika", "Unknown"], byId), 1); + }); +}); + +describe("baselineTeamCost", () => { + it("is C0R0 floor + 0.5", () => { + assert.equal( + baselineTeamCost(["Mavuika", "Iansan", "Chevreuse", "Ororon"], byId), + 1.5, + ); + assert.equal( + baselineTeamCost(["Iansan", "Chevreuse", "Ororon", "Mona"], byId), + 0.5, + ); + }); +}); + +describe("labeledCostWithinFloor", () => { + const members = ["Mavuika", "Iansan", "Chevreuse", "Ororon"] as const; + + it("allows scrape cost <= C0R0 + 0.5", () => { + assert.equal(labeledCostWithinFloor(0, members, byId), true); + assert.equal(labeledCostWithinFloor(1, members, byId), true); + assert.equal(labeledCostWithinFloor(1.5, members, byId), true); + }); + + it("rejects a full extra limited copy (+1)", () => { + assert.equal(labeledCostWithinFloor(2, members, byId), false); + }); +}); + +describe("bestClearUnderLimit / clearTimeAtCap", () => { + const frontier = [ + { c: 1, t: 90 }, + { c: 1.5, t: 85 }, + { c: 2, t: 30 }, + { c: 6, t: 20 }, + ]; + + it("picks the fastest point at or under the scrape limit", () => { + assert.deepEqual(bestClearUnderLimit(frontier, 1.5), { c: 1.5, t: 85 }); + assert.deepEqual(bestClearUnderLimit(frontier, 8.5), { c: 6, t: 20 }); + assert.equal(bestClearUnderLimit(frontier, 0.5), null); + }); + + it("keeps the slower baseline time when a faster +1-cost clear exists", () => { + const row = { frontier }; + assert.equal(clearTimeAtCostCeiling(row, 1), 85); + assert.equal(clearCostAtCap(row, 1), 1.5); + assert.equal(clearTimeAtCap(row, 2), 30); + assert.equal(clearCostAtCap(row, 2), 2); + }); + + it("uses high-cap frontier points above 4", () => { + const row = { frontier }; + assert.equal(clearTimeAtCap(row, 8), 20); + assert.equal(clearCostAtCap(row, 8), 6); + }); + + it("normalizes RPC cost/time aliases and skips malformed points", () => { + assert.deepEqual( + normalizeClearFrontier([ + { cost: 1.5, time: 90 }, + { c: 2, time_s: 30 }, + null, + "nope", + { cost: "1", t: 10 }, + { c: 3, t: 40 }, + ]), + [ + { c: 1.5, t: 90 }, + { c: 2, t: 30 }, + { c: 3, t: 40 }, + ], + ); + assert.deepEqual(normalizeClearFrontier(null), []); + }); +}); + +describe("c0r0ClearPairKeys", () => { + it("keeps pairs with a clear under floor + 0.5 slack", () => { + const keys = c0r0ClearPairKeys( + [ + { + team_key: "ol", + enemy_id: 1, + members: ["Mavuika", "Iansan", "Chevreuse", "Ororon"], + frontier: [ + { c: 1.5, t: 90 }, + { c: 2, t: 30 }, + ], + }, + { + team_key: "ol", + enemy_id: 2, + members: ["Mavuika", "Iansan", "Chevreuse", "Ororon"], + frontier: [{ c: 2, t: 25 }], + }, + { + team_key: "f2p", + enemy_id: 3, + members: ["Iansan", "Chevreuse", "Ororon", "Mona"], + frontier: [{ c: 0.5, t: 60 }], + }, + ], + byId, + ); + assert.deepEqual([...keys].sort(), ["f2p|3", "ol|1"]); + }); + + it("counts a 3.5-cost clear for a floor-3 team", () => { + const threeLimited = ["Mavuika", "Mualani", "Skirk", "Iansan"] as const; + const map = new Map(byId); + map.set("Skirk", { + game_id: 9, + name_id: "Skirk", + name: "Skirk", + rarity: 5, + }); + assert.equal(baselineTeamCost(threeLimited, map), 3.5); + + const keys = c0r0ClearPairKeys( + [ + { + team_key: "heavy", + enemy_id: 1, + members: [...threeLimited], + frontier: [{ c: 3.5, t: 55 }], + }, + ], + map, + ); + assert.deepEqual([...keys], ["heavy|1"]); + }); +}); diff --git a/src/lib/team-cost.ts b/src/lib/team-cost.ts new file mode 100644 index 0000000..9be6896 --- /dev/null +++ b/src/lib/team-cost.ts @@ -0,0 +1,178 @@ +/** + * Pull-cost floor + Fearless clear frontier helpers. + * + * Floor (C0R0) matches CostPopover: each limited 5★ copy is +1 (no weapons). + * Baseline scrape allowance is floor + 0.5 so stygian.moe standard-weapon + * labels still count; a full extra limited copy (+1) does not. + * + * RPC rows carry a cost/time Pareto `frontier`; clients pick the best point + * under an inclusive scrape-cost limit (cap + 0.5 slack). + */ + +import type { + StygianClearFrontierPoint, + StygianCheapClearFrontier, +} from "$lib/definitions"; +import { isLimitedFiveStar, type CharacterMeta } from "$lib/tierlist"; + +/** Max limited 5★s on a 4-man team — used when fetching enough frontier span. */ +export const STYGIAN_C0R0_CLEAR_MAX_COST = 4; + +/** Standard-weapon scrape slack (stygian.moe labels these +0.5). */ +export const STYGIAN_BASELINE_COST_SLACK = 0.5; + +/** Character-only C0R0 floor: one per limited event-banner 5★. */ +export function floorTeamCost( + nameIds: readonly (string | null | undefined)[], + characterByNameId: ReadonlyMap, +): number { + let cost = 0; + for (const id of nameIds) { + if (!id) continue; + const character = characterByNameId.get(id); + if (character && isLimitedFiveStar(character)) cost += 1; + } + return cost; +} + +/** + * Max scrape cost that still counts as baseline for this roster composition: + * C0R0 floor + 0.5 (standard weapon), not a full extra limited copy. + */ +export function baselineTeamCost( + nameIds: readonly (string | null | undefined)[], + characterByNameId: ReadonlyMap, +): number { + return floorTeamCost(nameIds, characterByNameId) + STYGIAN_BASELINE_COST_SLACK; +} + +/** Inclusive scrape-cost limit for a user / floor cap (adds +0.5 slack). */ +export function scrapeCostLimitForCap(maxCost: number): number { + if (!Number.isFinite(maxCost) || maxCost < 0) return STYGIAN_BASELINE_COST_SLACK; + return maxCost + STYGIAN_BASELINE_COST_SLACK; +} + +/** Normalize RPC / test frontier payloads into `{c,t}` points. */ +export function normalizeClearFrontier( + frontier: StygianCheapClearFrontier["frontier"] | null | undefined, +): StygianClearFrontierPoint[] { + if (!Array.isArray(frontier)) return []; + const points: StygianClearFrontierPoint[] = []; + for (const raw of frontier) { + if (!raw || typeof raw !== "object") continue; + const record = raw as Record; + const c = record.c ?? record.cost; + const t = record.t ?? record.time ?? record.time_s; + if (typeof c !== "number" || !Number.isFinite(c)) continue; + if (typeof t !== "number" || !Number.isFinite(t)) continue; + points.push({ c, t }); + } + points.sort((a, b) => a.c - b.c || a.t - b.t); + return points; +} + +/** + * Fastest frontier point with scrape cost ≤ limit, or null if none. + * Ties on time keep the cheaper cost. + */ +export function bestClearUnderLimit( + frontier: StygianCheapClearFrontier["frontier"] | null | undefined, + limit: number, +): StygianClearFrontierPoint | null { + if (!Number.isFinite(limit)) return null; + let best: StygianClearFrontierPoint | null = null; + for (const point of normalizeClearFrontier(frontier)) { + if (point.c > limit) continue; + if ( + best == null || + point.t < best.t || + (point.t === best.t && point.c < best.c) + ) { + best = point; + } + } + return best; +} + +/** Fastest clear under character-cost cap `maxCost` (+0.5 slack). */ +export function clearTimeAtCap( + row: StygianCheapClearFrontier, + maxCost: number, +): number | null { + return bestClearUnderLimit(row.frontier, scrapeCostLimitForCap(maxCost))?.t ?? null; +} + +/** Scrape cost of the clear that set {@link clearTimeAtCap}. */ +export function clearCostAtCap( + row: StygianCheapClearFrontier, + maxCost: number, +): number | null { + return bestClearUnderLimit(row.frontier, scrapeCostLimitForCap(maxCost))?.c ?? null; +} + +/** + * Fastest clear under a character-floor ceiling (integer limited-5★ count), + * with +0.5 standard-weapon slack. + */ +export function clearTimeAtCostCeiling( + row: StygianCheapClearFrontier, + ceiling: number, +): number | null { + if (!Number.isFinite(ceiling) || ceiling < 0) return null; + return bestClearUnderLimit(row.frontier, scrapeCostLimitForCap(ceiling))?.t ?? null; +} + +/** Scrape cost of the clear that set {@link clearTimeAtCostCeiling}. */ +export function clearCostAtCostCeiling( + row: StygianCheapClearFrontier, + ceiling: number, +): number | null { + if (!Number.isFinite(ceiling) || ceiling < 0) return null; + return bestClearUnderLimit(row.frontier, scrapeCostLimitForCap(ceiling))?.c ?? null; +} + +/** True when scrape label ≤ baseline team cost (C0R0 + 0.5). */ +export function labeledCostWithinFloor( + labeledCost: number, + nameIds: readonly (string | null | undefined)[], + characterByNameId: ReadonlyMap, +): boolean { + if (!Number.isFinite(labeledCost)) return false; + return labeledCost <= baselineTeamCost(nameIds, characterByNameId); +} + +/** + * True when this team×enemy row has a Fearless clear within baseline + * (scrape cost ≤ C0R0 + 0.5). + */ +export function hasBaselineClear( + row: StygianCheapClearFrontier & { + members: string[] | null; + }, + characterByNameId: ReadonlyMap, +): boolean { + const members = row.members ?? []; + const limit = baselineTeamCost(members, characterByNameId); + return bestClearUnderLimit(row.frontier, limit) != null; +} + +/** + * team_key|enemy_id pairs with a Fearless clear at or under baseline + * (C0R0 + 0.5) for that composition. + */ +export function c0r0ClearPairKeys( + rows: readonly (StygianCheapClearFrontier & { + team_key: string | null; + enemy_id: number; + members: string[] | null; + })[], + characterByNameId: ReadonlyMap, +): Set { + const keys = new Set(); + for (const row of rows) { + if (!row.team_key) continue; + if (!hasBaselineClear(row, characterByNameId)) continue; + keys.add(`${row.team_key}|${row.enemy_id}`); + } + return keys; +} diff --git a/src/lib/types/database.types.ts b/src/lib/types/database.types.ts index 62aa8ea..cfe2860 100644 --- a/src/lib/types/database.types.ts +++ b/src/lib/types/database.types.ts @@ -390,6 +390,55 @@ export type Database = { }, ] } + stygian_team_clear_videos: { + Row: { + char_names: string[] + clear_key: string + cost: number | null + difficulty: string + enemy_id: number + team_key: string + time_s: number | null + updated_at: string + video_url: string + } + Insert: { + char_names?: string[] + clear_key: string + cost?: number | null + difficulty: string + enemy_id: number + team_key: string + time_s?: number | null + updated_at?: string + video_url: string + } + Update: { + char_names?: string[] + clear_key?: string + cost?: number | null + difficulty?: string + enemy_id?: number + team_key?: string + time_s?: number | null + updated_at?: string + video_url?: string + } + Relationships: [ + { + foreignKeyName: "stygian_team_clear_videos_enemy_id_fkey" + columns: ["enemy_id"] + referencedRelation: "enemies" + referencedColumns: ["id"] + }, + { + foreignKeyName: "stygian_team_clear_videos_team_key_fkey" + columns: ["team_key"] + referencedRelation: "teams" + referencedColumns: ["team_key"] + }, + ] + } stygian_version_enemies: { Row: { enemy_id: number @@ -855,6 +904,55 @@ export type Database = { usage_total: number }[] } + get_stygian_cheap_clears_for_roster: + | { + Args: { + p_difficulty?: string + p_enemy_ids: number[] + p_name_ids: string[] + p_version_number: number + } + Returns: { + avg_usage_rate: number + enemy_id: number + field_1_rate: number + field_2_rate: number + field_3_rate: number + has_total: number + members: string[] + members_names: string[] + min_cost: number + team_key: string + usage_rate: number + usage_total: number + version_number: number + }[] + } + | { + Args: { + p_difficulty?: string + p_enemy_ids: number[] + p_max_cost?: number + p_name_ids: string[] + p_version_number: number + } + Returns: { + avg_usage_rate: number + enemy_id: number + field_1_rate: number + field_2_rate: number + field_3_rate: number + frontier: Json + has_total: number + members: string[] + members_names: string[] + min_cost: number + team_key: string + usage_rate: number + usage_total: number + version_number: number + }[] + } get_teams_with_characters_subset: { Args: { p_name_ids: string[]; p_version_number: number } Returns: { diff --git a/src/lib/types/investment.ts b/src/lib/types/investment.ts index 1b55d23..cf018b0 100644 --- a/src/lib/types/investment.ts +++ b/src/lib/types/investment.ts @@ -54,6 +54,8 @@ export interface InvestmentSim { dps: number; /** Per-character build snapshot for this simulation. */ characters: CharacterBuild[]; + /** Baseline reaction profile (merge pass-through from summary). */ + reactions?: TeamReactionProfile; } export interface CharacterBuild { @@ -182,6 +184,19 @@ export interface CharacterIndex { * `artifact_importance.py` reports. Negative per-team gains are floored to 0. */ artifact_importance?: CharacterArtifactImportance; + /** + * Stat goals from mid→high OptimFull allocation deltas (+ ER if burst, CR if Fav). + * Negligible artifact impact collapses to the ER/Fav checklist only. + * Character-level summary — prefer ``build_examples`` for team/archetype views. + */ + stat_recommendations?: CharacterStatRecommendations; + /** + * One example per reaction fingerprint (highest baseline DPS), capped. + * `invest: mid` (negligible) → baseline ER (+ CR if that example uses Fav); + * `high` → high config + mains. + * Shown on the character Builds tab. + */ + build_examples?: CharacterBuildExample[]; /** * Editorial upgrade recommendations from a hand-authored guide. * Published separately from measured `*_importance` statistics; the Builds @@ -310,6 +325,52 @@ export interface CharacterArtifactImportance { tier?: ImportanceImpactTier | null; } +/** + * Derived farm targets: mid→high liquid movers, plus conditional ER/Fav overlays. + * `checklist` mode (negligible artifact impact) omits delta_stats. + */ +export interface CharacterStatRecommendations { + mode: "delta" | "checklist"; + delta_stats: Array<{ key: string; mean_delta: number; teams_positive: number }>; + enerRech_if_burst: boolean; + critRate_if_fav: boolean; + teams: number; + burst_teams: number; + fav_teams: number; +} + +/** One concrete team build example for a character (CDN character index). */ +export interface CharacterBuildExample { + team_key: string; + team_name: string; + /** GOOD keys for the full party (order matches the sim). */ + characters: string[]; + state_key: string; + reactions: TeamReactionProfile; + /** + * ``mid`` = negligible artifact impact → UI shows baseline ER (+ CR if Fav). + * ``high`` = otherwise → UI shows high OptimFull sheet from mains + high rolls. + */ + invest: "mid" | "high"; + artifact_pct_gain: number; + /** Featured character GOOD key (same shape as ``CharacterBuild``). */ + key: string; + cons: number; + level: number; + talents: CharacterBuild["talents"]; + weapon: CharacterBuild["weapon"]; + set: CharacterBuild["set"]; + set2?: string; + set2_count?: number; + main_stats: CharacterBuild["main_stats"]; + /** Baseline (mid-invest) OptimFull total rolls. */ + substat_rolls: Record; + substat_rolls_liquid: Record; + /** High-invest OptimFull totals when ``invest === "high"``. */ + high_substat_rolls?: Record; + high_substat_rolls_liquid?: Record; +} + /** @deprecated Use {@link ImportanceImpactTier}. */ export type ArtifactImportanceTier = ImportanceImpactTier; diff --git a/src/lib/ui/components/InfoPopover.svelte b/src/lib/ui/components/InfoPopover.svelte index b15b4e0..6707176 100644 --- a/src/lib/ui/components/InfoPopover.svelte +++ b/src/lib/ui/components/InfoPopover.svelte @@ -5,15 +5,27 @@ let { label, children, + icon, class: className = "", + panelClass = "", align = "center", + anchorSelector = "", }: { /** Inline trigger text — underlined to signal it explains itself. */ label: string; children: Snippet; + /** Optional leading icon inside the trigger button. */ + icon?: Snippet; class?: string; + /** Extra class on the floating panel (portaled to body). */ + panelClass?: string; /** Horizontal anchor of the panel relative to the trigger. */ align?: "start" | "center" | "end"; + /** + * When set, panel left + width match `trigger.closest(anchorSelector)` + * (e.g. a column). Vertical placement still uses the trigger. + */ + anchorSelector?: string; } = $props(); const EDGE = 8; @@ -33,6 +45,12 @@ open = false; } + function anchorBox(trigger: HTMLElement): DOMRect | null { + if (!anchorSelector) return null; + const el = trigger.closest(anchorSelector); + return el instanceof HTMLElement ? el.getBoundingClientRect() : null; + } + /** Viewport-fixed placement so overflow:hidden boards can't clip the panel. */ function placePanel() { const trigger = triggerEl; @@ -40,11 +58,19 @@ if (!trigger || !panel) return; const rect = trigger.getBoundingClientRect(); + const box = anchorBox(trigger); const vw = window.innerWidth; const vh = window.innerHeight; - panel.style.maxWidth = `${Math.max(0, vw - EDGE * 2)}px`; panel.style.maxHeight = ""; + if (box) { + const width = Math.max(0, Math.min(box.width, vw - EDGE * 2)); + panel.style.width = `${width}px`; + panel.style.maxWidth = `${width}px`; + } else { + panel.style.width = ""; + panel.style.maxWidth = ""; + } const panelRect = panel.getBoundingClientRect(); const aboveTop = rect.top - panelRect.height - GAP; @@ -64,13 +90,24 @@ } const placed = panel.getBoundingClientRect(); - let left = - align === "start" - ? rect.left - : align === "end" - ? rect.right - placed.width - : rect.left + rect.width / 2 - placed.width / 2; - left = Math.max(EDGE, Math.min(left, vw - placed.width - EDGE)); + let left: number; + if (box) { + left = Math.max(EDGE, Math.min(box.left, vw - placed.width - EDGE)); + } else { + left = + align === "start" + ? rect.left + : align === "end" + ? rect.right - placed.width + : rect.left + rect.width / 2 - placed.width / 2; + left = Math.max(EDGE, Math.min(left, vw - placed.width - EDGE)); + } + + // Last-resort viewport clamp if content still overflows (e.g. unbroken strings). + if (!box && placed.width > vw - EDGE * 2) { + panel.style.maxWidth = `${vw - EDGE * 2}px`; + left = EDGE; + } panel.style.top = `${top}px`; panel.style.left = `${left}px`; @@ -129,19 +166,23 @@ {#if open} | null; class?: string; } = $props(); @@ -51,10 +58,20 @@ $equipmentVersion; return build.set2 ? (artifactSetByKey.get(build.set2) ?? null) : null; }); + let setCount = $derived(normalizeSetPieceCount(build.set.count) ?? 4); + let set2Count = $derived( + build.set2 ? normalizeSetPieceCount(build.set2_count ?? 2) : null, + ); let sheet = $derived(computeBuildSheetStats(build)); let wIcon = $derived(weapon ? weaponIconUrl(weapon.awakenIcon) : null); let sIcon = $derived(set ? artifactIconUrl(set.icon) : null); let s2Icon = $derived(set2 ? artifactIconUrl(set2.icon) : null); + let relevantKeySet = $derived.by(() => { + if (relevantKeys == null) return null; + return relevantKeys instanceof Set + ? relevantKeys + : new Set(relevantKeys); + }); function dmgBonusEntries(bag: SheetStatBag) { return Object.entries(bag.dmgBonus) @@ -70,12 +87,27 @@ if (stat === "critRate") return "critRate_"; if (stat === "critDMG") return "critDMG_"; if (stat === "enerRech") return "enerRech_"; + if (stat === "heal") return "heal_"; return stat; } + /** Sheet row key matches a GOOD key from ``relevantKeys``. */ + function sheetRowIsRelevant(rowKey: string, rel: Set): boolean { + if (rel.has(rowKey)) return true; + if (rowKey === "hp") return rel.has("hp") || rel.has("hp_"); + if (rowKey === "atk") return rel.has("atk") || rel.has("atk_"); + if (rowKey === "def") return rel.has("def") || rel.has("def_"); + if (rowKey === "critRate") return rel.has("critRate_"); + if (rowKey === "critDMG") return rel.has("critDMG_"); + if (rowKey === "enerRech") return rel.has("enerRech_"); + if (rowKey === "heal") return rel.has("heal_"); + if (rowKey === "eleMas") return rel.has("eleMas"); + return false; + } + let coreStats = $derived.by(() => { if (!sheet) return []; - return [ + const rows = [ { key: "hp", label: "HP", value: sheet.hp }, { key: "atk", label: "ATK", value: sheet.atk }, { key: "def", label: "DEF", value: sheet.def }, @@ -83,12 +115,19 @@ { key: "critRate", label: "CRIT Rate", value: sheet.critRate }, { key: "critDMG", label: "CRIT DMG", value: sheet.critDMG }, { key: "enerRech", label: "Energy Recharge", value: sheet.enerRech }, + { key: "heal", label: translateStatKey("heal_"), value: sheet.heal }, ...dmgBonusEntries(sheet).map(([key, value]) => ({ key, label: translateStatKey(key), value, })), ]; + const rel = relevantKeySet; + if (!rel) { + // Team-config default: full sheet without heal (usually 0 / unused). + return rows.filter((row) => row.key !== "heal"); + } + return rows.filter((row) => sheetRowIsRelevant(row.key, rel)); }); type TalentRow = { @@ -257,13 +296,13 @@ {/if}

{set?.name ?? build.set.key}

- {build.set.count} + {setCount} - {#if build.set2} + {#if build.set2 && set2Count != null}
{#if s2Icon} {/if}

{set2?.name ?? build.set2}

- {build.set2_count ?? 2} + {set2Count}
{/if} diff --git a/src/lib/ui/components/Select.svelte b/src/lib/ui/components/Select.svelte index fbe934d..a8ba435 100644 --- a/src/lib/ui/components/Select.svelte +++ b/src/lib/ui/components/Select.svelte @@ -213,7 +213,23 @@ onclick={toggle} {...rest} > - {triggerText} + + {#if trigger} + {triggerText} + {:else} + {#each options as opt (opt.value)} + + {opt.label} + + {:else} + {triggerText} + {/each} + {/if} + @@ -284,6 +300,23 @@ background: transparent; } + /* Stack every option label so the trigger width fits the longest. */ + .trigger-label { + display: inline-grid; + justify-items: start; + text-align: left; + } + + .trigger-option { + grid-area: 1 / 1; + visibility: hidden; + white-space: nowrap; + } + + .trigger-option.active { + visibility: visible; + } + .chevron { display: inline-flex; transition: transform 150ms ease; diff --git a/src/lib/ui/components/StygianSolutionBoard.svelte b/src/lib/ui/components/StygianSolutionBoard.svelte new file mode 100644 index 0000000..0ed1a82 --- /dev/null +++ b/src/lib/ui/components/StygianSolutionBoard.svelte @@ -0,0 +1,1017 @@ + + +{#snippet enemyLabel(slot: Slot, linkClass: string)} + {@const enemy = enemies?.[slot]} + {#if enemy} + {enemy.enemy_name ?? stygianSlotLabel[slot]} + {:else} + {stygianSlotLabel[slot]} + {/if} +{/snippet} + +{#snippet fieldColumn(slot: Slot)} + {@const enemy = enemies?.[slot]} + {@const assignment = solution?.assignments.find((a) => a.slot === slot)} + +
+ {#if enemy?.asset} + + {/if} + + +

+ {@render enemyLabel(slot, "field-heading-link")} +

+ +
+ {#if assignment} + {@const clears = clearsForSlot(slot)} + {@const time = timeForSlot(slot)} + + +
+ + {#if showingVideoClears && time != null} + {time}s + {:else} + {(assignment.team.usage_rate ?? 0).toFixed(1)}% usage + {/if} + + {slotRate(assignment.team, slot).toFixed(0)}% in this field +
+ + {#if clears.length > 0} + {@const shown = Math.min(clears.length, clearsShownLimit(slot))} +
+ + {#snippet icon()} + + {/snippet} + + {#if shown < clears.length} + + {/if} + +
+ {/if} + {:else if solution} +
+

No team available for this field

+
+ {:else} +
+

Set up your roster in Settings

+
+ {/if} +
+
+{/snippet} + +{#if loading} + +{:else if $staticBoardsError && $allTeamsStygian.length === 0} + + {#snippet action()} + + {/snippet} + +{:else} + +
+
+ {#if displaySolutions.length > 0 && !waitingForOwned && !waitingForCheapClears} + + Solution {safeIndex + 1} + of {displaySolutions.length} + + {:else} + Solutions + {/if} +
+ +
+ {#if variant === "dev" && needsCheapClears} + clearDifficulty, + (value: StygianClearDifficulty) => + setDisplayPreferences({ stygianClearDifficulty: value }) + } + /> + {/if} + + + + + + + + diff --git a/src/routes/dev/features/+page.ts b/src/routes/dev/features/+page.ts new file mode 100644 index 0000000..32d1b08 --- /dev/null +++ b/src/routes/dev/features/+page.ts @@ -0,0 +1,9 @@ +import type { PageLoad } from "./$types"; + +export const load: PageLoad = () => ({ + seo: { + title: "Stygian Fast Clears Demo — Lightkeepers", + description: + "Dev demo: fastest Fearless clears under a cost cap for your roster.", + }, +}); diff --git a/src/routes/dev/ui/+page.svelte b/src/routes/dev/ui/+page.svelte index f03bfc9..72b1840 100644 --- a/src/routes/dev/ui/+page.svelte +++ b/src/routes/dev/ui/+page.svelte @@ -465,6 +465,63 @@ TYPE_PAIRINGS.find((p) => p.id === typePairingId) ?? TYPE_PAIRINGS[0], ); + // ── Stat goals layout study ──────────────────────────────────────────── + type StatGoalDemoArchetype = { + id: string; + label: string; + invest: "mid" | "high"; + weapon: string; + set: string; + stats: { key: string; label: string; value: string }[]; + }; + + const STAT_GOAL_ARCHETYPES: StatGoalDemoArchetype[] = [ + { + id: "freeze", + label: "Freeze", + invest: "mid", + weapon: "Thrilling Tales", + set: "Noblesse 4pc", + stats: [ + { key: "enerRech_", label: "Energy Recharge", value: "186.5%" }, + ], + }, + { + id: "hyperbloom", + label: "Hyperbloom", + invest: "high", + weapon: "Dragon's Bane", + set: "Flower of Paradise Lost 4pc", + stats: [ + { key: "eleMas", label: "Elemental Mastery", value: "812" }, + { key: "enerRech_", label: "Energy Recharge", value: "148.2%" }, + ], + }, + { + id: "vape", + label: "Vape", + invest: "high", + weapon: "Favonius Codex", + set: "Emblem 4pc", + stats: [ + { key: "enerRech_", label: "Energy Recharge", value: "221.0%" }, + { key: "critRate_", label: "CRIT Rate", value: "62.4%" }, + { key: "critDMG_", label: "CRIT DMG", value: "142.8%" }, + ], + }, + ]; + + let statGoalArchetypeId = $state("hyperbloom"); + let statGoalMenuOpen = $state(false); + let statGoalArchetype = $derived( + STAT_GOAL_ARCHETYPES.find((a) => a.id === statGoalArchetypeId) ?? + STAT_GOAL_ARCHETYPES[1], + ); + let statGoalTeam = $derived($charactersOwned.slice(0, 4)); + let statGoalAlts = $derived( + STAT_GOAL_ARCHETYPES.filter((a) => a.id !== statGoalArchetypeId), + ); + const TIP_TONE_OPTIONS = [ { id: "current", @@ -940,7 +997,263 @@
- + +