diff --git a/.dockerignore b/.dockerignore index 992dfdb..7503d55 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,6 +2,7 @@ node_modules .svelte-kit .env* *.md +!patch-notes/** .git .gitignore -build \ No newline at end of file +build diff --git a/.github/workflows/patch-notes.yml b/.github/workflows/patch-notes.yml new file mode 100644 index 0000000..97e64ec --- /dev/null +++ b/.github/workflows/patch-notes.yml @@ -0,0 +1,150 @@ +name: Patch notes + +on: + push: + branches: + - main + paths: + - "patch-notes/**/*.md" + - "patch-notes/*.md" + +permissions: + contents: write + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 2 + + - name: Publish new / changed notes + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DISCORD_PATCH_WEBHOOK_URL: ${{ secrets.DISCORD_PATCH_WEBHOOK_URL }} + SITE_ORIGIN: https://lightkeepers.moe + run: | + python3 <<'PY' + import json, os, re, subprocess, urllib.request + from pathlib import Path + + root = Path("patch-notes") + note_re = re.compile(r"^\d{4}-\d{2}-\d{2}-.+\.md$", re.I) + + def changed_note_files() -> list[Path]: + before = subprocess.check_output( + ["git", "rev-parse", "HEAD^"], text=True + ).strip() + out = subprocess.check_output( + ["git", "diff", "--name-only", "--diff-filter=AM", before, "HEAD", "--", "patch-notes"], + text=True, + ) + files = [] + for line in out.splitlines(): + p = Path(line.strip()) + if p.name == "README.md": + continue + if note_re.match(p.name): + files.append(p) + return files + + def parse(path: Path) -> dict: + text = path.read_text(encoding="utf-8").lstrip("\ufeff").replace("\r\n", "\n") + m = re.match(r"^---\n([\s\S]*?)\n---\n([\s\S]*)$", text) + if not m: + raise SystemExit(f"{path}: missing frontmatter") + fields = {} + for line in m.group(1).split("\n"): + if ":" not in line: + continue + k, v = line.split(":", 1) + fields[k.strip()] = v.strip().strip("\"'") + for key in ("title", "date", "summary"): + if not fields.get(key): + raise SystemExit(f"{path}: missing {key}") + slug = path.stem + return { + "slug": slug, + "title": fields["title"], + "date": fields["date"], + "summary": fields["summary"], + "body": m.group(2).strip(), + } + + site = os.environ["SITE_ORIGIN"].rstrip("/") + webhook = os.environ.get("DISCORD_PATCH_WEBHOOK_URL", "").strip() + files = changed_note_files() + if not files: + print("No dated patch-note files changed.") + raise SystemExit(0) + + for path in sorted(files): + note = parse(path) + tag = f"patch-notes/{note['slug']}" + site_url = f"{site}/patch-notes/{note['slug']}" + notes = f"{note['summary']}\n\n{note['body']}\n\n---\nSite: {site_url}" + + existing = subprocess.run( + ["gh", "release", "view", tag], + capture_output=True, + text=True, + ) + if existing.returncode == 0: + print(f"Updating release {tag}") + subprocess.check_call( + [ + "gh", + "release", + "edit", + tag, + "--title", + note["title"], + "--notes", + notes, + ] + ) + else: + print(f"Creating release {tag}") + subprocess.check_call( + [ + "gh", + "release", + "create", + tag, + "--title", + note["title"], + "--notes", + notes, + "--target", + os.environ.get("GITHUB_SHA", "main"), + ] + ) + + if webhook: + embed = { + "title": note["title"], + "description": note["summary"], + "url": site_url, + "color": 0xC9A227, + "fields": [ + {"name": "Date", "value": note["date"], "inline": True}, + { + "name": "Links", + "value": f"[Site]({site_url}) · [GitHub]({os.environ.get('GITHUB_SERVER_URL', 'https://github.com')}/{os.environ.get('GITHUB_REPOSITORY', '')}/releases/tag/{tag})", + "inline": False, + }, + ], + } + req = urllib.request.Request( + webhook, + data=json.dumps({"embeds": [embed]}).encode(), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req) as resp: + print(f"Discord webhook status {resp.status} for {tag}") + else: + print("DISCORD_PATCH_WEBHOOK_URL unset — skipped Discord post") + PY diff --git a/package.json b/package.json index 8a6e22b..467a3b1 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "format": "prettier --write .", "format:check": "prettier --check .", - "test:unit": "tsx --test src/lib/tierlist.test.ts src/lib/server/cache.test.ts src/lib/server/request-validation.test.ts src/lib/character-teams.test.ts src/lib/character-builds.test.ts src/lib/investment-teams.test.ts src/lib/solver.test.ts src/lib/roster-snapshot.test.ts src/lib/upgrade-priority.test.ts src/lib/query-state.test.ts src/lib/nav-history.test.ts src/lib/utils.traveler.test.ts src/lib/traveler-kits.test.ts src/lib/crimson-witch.test.ts", + "test:unit": "tsx --test src/lib/tierlist.test.ts src/lib/server/cache.test.ts src/lib/server/request-validation.test.ts src/lib/character-teams.test.ts src/lib/character-builds.test.ts src/lib/investment-teams.test.ts src/lib/solver.test.ts src/lib/roster-snapshot.test.ts src/lib/upgrade-priority.test.ts src/lib/query-state.test.ts src/lib/nav-history.test.ts src/lib/utils.traveler.test.ts src/lib/traveler-kits.test.ts src/lib/crimson-witch.test.ts src/lib/patch-notes.test.ts src/lib/patch-notes-seen.test.ts", "test": "pnpm test:unit && pnpm exec playwright test", "sync:schedules": "tsx scripts/workflows/sync-schedules.ts", "sync:character-assets": "tsx scripts/workflows/sync-character-assets.ts", diff --git a/patch-notes/2026-08-10-roster-hotfix-and-patch-notes.md b/patch-notes/2026-08-10-roster-hotfix-and-patch-notes.md new file mode 100644 index 0000000..1a4ff37 --- /dev/null +++ b/patch-notes/2026-08-10-roster-hotfix-and-patch-notes.md @@ -0,0 +1,15 @@ +--- +title: Roster sync hotfix & patch notes +date: 2026-08-10 +summary: Fixed cloud roster sync for logged-in accounts — resave your roster if it didn’t stick. Also: this Patch notes feed. +--- + +## Roster sync hotfix + +Roster upload schema changed to fit requirements. + +**What to do:** open Settings → Roster and change one character once so the cloud copy catches up. + +## Patch notes + +There’s now a Patch notes page on the site (as well as GitHub and Discord). You’ll get a short popup when something new ships. diff --git a/patch-notes/README.md b/patch-notes/README.md new file mode 100644 index 0000000..70d8911 --- /dev/null +++ b/patch-notes/README.md @@ -0,0 +1,21 @@ +# Patch notes + +Markdown in this folder is the **source of truth** for Lightkeepers updates. + +## Authoring + +1. Add `YYYY-MM-DD-short-slug.md` (see existing files). +2. Frontmatter: + +```yaml +--- +title: Short title +date: 2026-08-10 +summary: One-line blurb for the index, Discord embed, and GitHub Release. +--- +``` + +3. Body is GitHub-flavored markdown (headings, lists, links, bold/italic). +4. Merge to `main`. The **Patch notes** workflow creates a GitHub Release and posts to Discord when files here change (requires `DISCORD_PATCH_WEBHOOK_URL` secret). + +The website reads these files at `/patch-notes`. diff --git a/src/lib/character-builds.test.ts b/src/lib/character-builds.test.ts index 1e062e1..3027286 100644 --- a/src/lib/character-builds.test.ts +++ b/src/lib/character-builds.test.ts @@ -1,558 +1,650 @@ -/** - * Unit tests for character Builds-tab helpers. - * - * Run: pnpm test:unit - */ -import assert from "node:assert/strict"; -import { describe, it } from "node:test"; -import { - constellationImpactRows, - constellationPrioritySection, - levelImportanceFromBuilds, - levelPrioritySection, - rankSigWeaponsByGain, - rankWeaponsByRarityAndTeams, - recommendedSubstatsFromBuilds, - sigWeaponPrioritySection, - talentImportanceRows, - talentPrioritySection, - useGuideSection, -} from "./character-builds.ts"; -import type { - CharacterIndex, - CharacterTalentImportance, -} from "./types/investment.ts"; - -function builds( - partial: Partial & - Pick, -): CharacterIndex { - return { - key: "Test", - weapons: [], - sets: [], - ...partial, - }; -} - -describe("recommendedSubstatsFromBuilds", () => { - it("keeps liquid ranks above 0.5 and mains that can roll as subs", () => { - const result = recommendedSubstatsFromBuilds( - builds({ - main_stats: { - sands: [{ key: "eleMas", teams: 3 }], - goblet: [{ key: "hydro_dmg_", teams: 3 }], - circlet: [{ key: "critRate_", teams: 3 }], - }, - substat_rolls_liquid: { - teams: 1, - configs: 1, - mean: {}, - ranked: [ - { key: "critDMG_", mean: 2.1 }, - { key: "atk_", mean: 0.2 }, - { key: "eleMas", mean: 0.1 }, - ], - }, - }), - ); - assert.deepEqual( - result.map((r) => [r.key, r.matchesMain, r.mean]), - [ - ["eleMas", true, 0.1], - ["critRate_", true, 0], - ["critDMG_", false, 2.1], - ], - ); - }); - - it("keeps guide-authored ranks when there are no measured teams", () => { - const result = recommendedSubstatsFromBuilds( - builds({ - main_stats: { - sands: [{ key: "def_", teams: 0 }], - goblet: [{ key: "def_", teams: 0 }], - circlet: [ - { key: "critRate_", teams: 0 }, - { key: "critDMG_", teams: 0 }, - ], - }, - substat_rolls_liquid: { - teams: 0, - configs: 0, - mean: { enerRech_: 0, eleMas: 0 }, - ranked: [ - { key: "enerRech_", mean: 0 }, - { key: "eleMas", mean: 0 }, - ], - }, - }), - ); - assert.deepEqual( - result.map((r) => r.key), - ["critDMG_", "critRate_", "def_", "eleMas", "enerRech_"], - ); - }); -}); - -describe("rankWeaponsByRarityAndTeams", () => { - it("sorts by rarity then team count", () => { - const ranked = rankWeaponsByRarityAndTeams( - [ - { key: "fourA", teams: 9 }, - { key: "fiveB", teams: 2 }, - { key: "fiveA", teams: 5 }, - ], - (key) => (key.startsWith("five") ? 5 : 4), - ); - assert.deepEqual( - ranked.map((w) => w.key), - ["fiveA", "fiveB", "fourA"], - ); - }); -}); - -describe("rankSigWeaponsByGain", () => { - it("sorts and classifies by the stronger mean/median gain", () => { - const ranked = rankSigWeaponsByGain([ - { - key: "B", - teams: 1, - mean_pct_gain: 10, - median_pct_gain: 10, - min_pct_gain: 10, - max_pct_gain: 10, - }, - { - key: "A", - teams: 1, - mean_pct_gain: 10, - median_pct_gain: 10, - min_pct_gain: 10, - max_pct_gain: 10, - }, - { - key: "C", - teams: 1, - mean_pct_gain: 20, - median_pct_gain: 8, - min_pct_gain: 8, - max_pct_gain: 20, - }, - ]); - assert.deepEqual( - ranked.map((w) => [w.key, w.priority]), - [ - ["C", "exceptional"], - ["A", "solid"], - ["B", "solid"], - ], - ); - }); - - it("sorts non-finite gains last", () => { - const ranked = rankSigWeaponsByGain([ - { - key: "nan", - teams: 1, - mean_pct_gain: Number.NaN, - median_pct_gain: Number.NaN, - min_pct_gain: 0, - max_pct_gain: 0, - }, - { - key: "small", - teams: 1, - mean_pct_gain: 1, - median_pct_gain: 1, - min_pct_gain: 1, - max_pct_gain: 1, - }, - { - key: "infinite", - teams: 1, - mean_pct_gain: Number.POSITIVE_INFINITY, - median_pct_gain: 0, - min_pct_gain: 0, - max_pct_gain: 0, - }, - { - key: "big", - teams: 1, - mean_pct_gain: 24, - median_pct_gain: 24, - min_pct_gain: 24, - max_pct_gain: 24, - }, - ]); - assert.deepEqual( - ranked.map((w) => w.key), - ["big", "small", "infinite", "nan"], - ); - }); -}); - -describe("constellationImpactRows", () => { - it("preserves source order and classifies each gain", () => { - const rows = constellationImpactRows([ - { - cons: 1, - teams: 2, - mean_pct_gain: 4, - median_pct_gain: 6, - min_pct_gain: 2, - max_pct_gain: 7, - }, - { - cons: 2, - teams: 2, - mean_pct_gain: 21, - median_pct_gain: 18, - min_pct_gain: 15, - max_pct_gain: 25, - }, - ]); - assert.deepEqual( - rows.map((row) => [row.cons, row.pct, row.priorityLabel]), - [ - [1, 6, "Modest impact"], - [2, 21, "Exceptional impact"], - ], - ); - }); - - it("classifies measured constellation gains on the five-band ladder", () => { - const rows = constellationImpactRows([ - { - cons: 1, - teams: 2, - mean_pct_gain: 30, - median_pct_gain: 30, - min_pct_gain: 30, - max_pct_gain: 30, - }, - ]); - assert.deepEqual( - rows.map((row) => [row.priority, row.priorityLabel]), - [["exceptional", "Exceptional impact"]], - ); - }); -}); - -describe("talentImportanceRows / levelImportanceFromBuilds", () => { - it("orders talent rows by max mean/median and classifies level impact", () => { - const rows = talentImportanceRows( - { - teams: 2, - auto: { - mean_pct_drop: 3, - median_pct_drop: 3, - min_pct_drop: 3, - max_pct_drop: 3, - }, - skill: { - mean_pct_drop: 12, - median_pct_drop: 12, - min_pct_drop: 12, - max_pct_drop: 12, - }, - burst: { - mean_pct_drop: 5, - median_pct_drop: 5, - min_pct_drop: 5, - max_pct_drop: 5, - }, - priority: ["skill", "burst", "auto"], - }, - (kitType) => `icon:${kitType}`, - ); - assert.deepEqual( - rows.map((r) => [r.slot, r.icon, r.priority]), - [ - ["skill", "icon:skill", "exceptional"], - ["burst", "icon:burst", "solid"], - ["auto", "icon:normal", "modest"], - ], - ); - - const level = levelImportanceFromBuilds( - builds({ - main_stats: { sands: [], goblet: [], circlet: [] }, - substat_rolls_liquid: { teams: 0, configs: 0, mean: {}, ranked: [] }, - level_importance: { - teams: 2, - mean_pct_drop: 6, - median_pct_drop: 6, - min_pct_drop: 6, - max_pct_drop: 6, - }, - }), - ); - assert.equal(level?.priority, "high"); - }); - - it("hides talent/level rows when teams are zero", () => { - assert.equal( - talentImportanceRows( - { - teams: 0, - auto: { - mean_pct_drop: 0, - median_pct_drop: 0, - min_pct_drop: 0, - max_pct_drop: 0, - }, - skill: { - mean_pct_drop: 0, - median_pct_drop: 0, - min_pct_drop: 0, - max_pct_drop: 0, - }, - burst: { - mean_pct_drop: 0, - median_pct_drop: 0, - min_pct_drop: 0, - max_pct_drop: 0, - }, - priority: ["auto", "skill", "burst"], - }, - () => null, - ).length, - 0, - ); - assert.equal( - levelImportanceFromBuilds( - builds({ - main_stats: { sands: [], goblet: [], circlet: [] }, - substat_rolls_liquid: { teams: 0, configs: 0, mean: {}, ranked: [] }, - level_importance: { - teams: 0, - mean_pct_drop: 0, - median_pct_drop: 0, - min_pct_drop: 0, - max_pct_drop: 0, - }, - }), - ), - null, - ); - }); -}); - -describe("guide vs sim section selection", () => { - const emptyShell = { - main_stats: { - sands: [], - goblet: [], - circlet: [], - } as CharacterIndex["main_stats"], - substat_rolls_liquid: { - teams: 0, - configs: 0, - mean: {}, - ranked: [], - }, - }; - - const simTalent: CharacterTalentImportance = { - teams: 2, - auto: { - mean_pct_drop: 3, - median_pct_drop: 3, - min_pct_drop: 3, - max_pct_drop: 3, - }, - skill: { - mean_pct_drop: 12, - median_pct_drop: 12, - min_pct_drop: 12, - max_pct_drop: 12, - }, - burst: { - mean_pct_drop: 5, - median_pct_drop: 5, - min_pct_drop: 5, - max_pct_drop: 5, - }, - priority: ["skill", "burst", "auto"], - }; - - it("fills missing sim sections from guide_priority by default", () => { - assert.equal(useGuideSection({ override: false }, true, false), true); - assert.equal(useGuideSection({ override: false }, true, true), false); - assert.equal(useGuideSection({ override: true }, true, true), true); - assert.equal(useGuideSection({ override: true }, false, true), false); - - const section = talentPrioritySection( - builds({ - ...emptyShell, - guide_priority: { - override: false, - talent_priority: ["burst", "skill", "auto"], - }, - }), - (kitType) => `icon:${kitType}`, - ); - assert.equal(section?.source, "guide"); - assert.deepEqual( - section?.source === "guide" - ? section.rows.map((r) => [r.slot, r.icon]) - : null, - [ - ["burst", "icon:burst"], - ["skill", "icon:skill"], - ["auto", "icon:normal"], - ], - ); - - assert.deepEqual( - levelPrioritySection( - builds({ - ...emptyShell, - guide_priority: { override: false, level_90: true }, - }), - ), - { - source: "guide", - simMissing: true, - priority: "solid", - priorityLabel: "Recommended", - }, - ); - - const cons = constellationPrioritySection( - builds({ - ...emptyShell, - guide_priority: { override: false, constellations: [2, 1] }, - }), - ); - assert.equal(cons?.source, "guide"); - assert.deepEqual( - cons?.source === "guide" - ? cons.rows.map((r) => [r.cons, r.priority, r.priorityLabel]) - : null, - [ - [1, "high", "High impact"], - [2, "high", "High impact"], - ], - ); - - const sigs = sigWeaponPrioritySection( - builds({ - ...emptyShell, - guide_priority: { - override: false, - sig_weapons: ["Elegy", "Skyward"], - }, - }), - ); - assert.equal(sigs?.source, "guide"); - assert.deepEqual( - sigs?.source === "guide" - ? sigs.rows.map((r) => [r.key, r.priority, r.priorityLabel]) - : null, - [ - ["Elegy", "high", "High impact"], - ["Skyward", "high", "High impact"], - ], - ); - }); - - it("marks guide sections that replace existing sim data as measured", () => { - const section = constellationPrioritySection( - builds({ - ...emptyShell, - vertical_importance: { - constellations: [ - { - cons: 1, - teams: 2, - mean_pct_gain: 20, - median_pct_gain: 20, - min_pct_gain: 20, - max_pct_gain: 20, - }, - ], - sig_weapons: [], - }, - guide_priority: { override: true, constellations: [2] }, - }), - ); - assert.equal(section?.source, "guide"); - assert.equal( - section?.source === "guide" ? section.simMissing : null, - false, - ); - }); - - it("keeps measured five-band rows when sim data exists and override is off", () => { - const section = talentPrioritySection( - builds({ - ...emptyShell, - talent_importance: simTalent, - guide_priority: { - override: false, - talent_priority: ["burst", "auto", "skill"], - }, - }), - () => null, - ); - assert.equal(section?.source, "sim"); - assert.deepEqual( - section?.source === "sim" - ? section.rows.map((r) => [r.slot, r.priority]) - : null, - [ - ["skill", "exceptional"], - ["burst", "solid"], - ["auto", "modest"], - ], - ); - }); - - it("replaces authored sections when override is true, leaving omitted sections on sim", () => { - const talent = talentPrioritySection( - builds({ - ...emptyShell, - talent_importance: simTalent, - level_importance: { - teams: 2, - mean_pct_drop: 6, - median_pct_drop: 6, - min_pct_drop: 6, - max_pct_drop: 6, - }, - guide_priority: { - override: true, - talent_priority: ["burst", "skill", "auto"], - // level_90 omitted → keep sim level - }, - }), - () => null, - ); - assert.equal(talent?.source, "guide"); - assert.deepEqual( - talent?.source === "guide" ? talent.rows.map((r) => r.slot) : null, - ["burst", "skill", "auto"], - ); - - const level = levelPrioritySection( - builds({ - ...emptyShell, - level_importance: { - teams: 2, - mean_pct_drop: 6, - median_pct_drop: 6, - min_pct_drop: 6, - max_pct_drop: 6, - }, - guide_priority: { - override: true, - talent_priority: ["burst", "skill", "auto"], - }, - }), - ); - assert.equal(level?.source, "sim"); - assert.equal(level?.source === "sim" ? level.row.priority : null, "high"); - }); -}); +/** + * Unit tests for character Builds-tab helpers. + * + * Run: pnpm test:unit + */ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + constellationImpactRows, + constellationPrioritySection, + formatReactionFingerprint, + formatReactionName, + levelImportanceFromBuilds, + levelPrioritySection, + rankSigWeaponsByGain, + rankWeaponsByRarityAndTeams, + recommendedSubstatsFromBuilds, + sigWeaponPrioritySection, + talentImportanceRows, + talentPrioritySection, + useGuideSection, +} from "./character-builds.ts"; +import type { + CharacterIndex, + CharacterTalentImportance, +} from "./types/investment.ts"; + +function builds( + partial: Partial & + Pick, +): CharacterIndex { + return { + key: "Test", + weapons: [], + sets: [], + ...partial, + }; +} + +describe("recommendedSubstatsFromBuilds", () => { + it("keeps liquid ranks above 0.5 and mains that can roll as subs", () => { + const result = recommendedSubstatsFromBuilds( + builds({ + main_stats: { + sands: [{ key: "eleMas", teams: 3 }], + goblet: [{ key: "hydro_dmg_", teams: 3 }], + circlet: [{ key: "critRate_", teams: 3 }], + }, + substat_rolls_liquid: { + teams: 1, + configs: 1, + mean: {}, + ranked: [ + { key: "critDMG_", mean: 2.1 }, + { key: "atk_", mean: 0.2 }, + { key: "eleMas", mean: 0.1 }, + ], + }, + }), + ); + assert.deepEqual( + result.map((r) => [r.key, r.matchesMain, r.mean]), + [ + ["eleMas", true, 0.1], + ["critRate_", true, 0], + ["critDMG_", false, 2.1], + ], + ); + }); + + it("keeps guide-authored ranks when there are no measured teams", () => { + const result = recommendedSubstatsFromBuilds( + builds({ + main_stats: { + sands: [{ key: "def_", teams: 0 }], + goblet: [{ key: "def_", teams: 0 }], + circlet: [ + { key: "critRate_", teams: 0 }, + { key: "critDMG_", teams: 0 }, + ], + }, + substat_rolls_liquid: { + teams: 0, + configs: 0, + mean: { enerRech_: 0, eleMas: 0 }, + ranked: [ + { key: "enerRech_", mean: 0 }, + { key: "eleMas", mean: 0 }, + ], + }, + }), + ); + assert.deepEqual( + result.map((r) => r.key), + ["critDMG_", "critRate_", "def_", "eleMas", "enerRech_"], + ); + }); +}); + +describe("rankWeaponsByRarityAndTeams", () => { + it("sorts by rarity then team count", () => { + const ranked = rankWeaponsByRarityAndTeams( + [ + { key: "fourA", teams: 9 }, + { key: "fiveB", teams: 2 }, + { key: "fiveA", teams: 5 }, + ], + (key) => (key.startsWith("five") ? 5 : 4), + ); + assert.deepEqual( + ranked.map((w) => w.key), + ["fiveA", "fiveB", "fourA"], + ); + }); + + it("prefers measured sigs when rarity and teams tie", () => { + const ranked = rankWeaponsByRarityAndTeams( + [ + { key: "SurfsUp", teams: 5 }, + { key: "TomeOfTheEternalFlow", teams: 5 }, + { key: "PrototypeAmber", teams: 5 }, + ], + () => 5, + ["TomeOfTheEternalFlow"], + ); + assert.deepEqual( + ranked.map((w) => w.key), + ["TomeOfTheEternalFlow", "PrototypeAmber", "SurfsUp"], + ); + }); + + it("sorts by Bradley-Terry strength before team count", () => { + const ranked = rankWeaponsByRarityAndTeams( + [ + { key: "SurfsUp", teams: 5, strength: 0.9 }, + { key: "TomeOfTheEternalFlow", teams: 5, strength: 1.2 }, + { key: "PrototypeAmber", teams: 8, strength: 0.7 }, + ], + () => 5, + ); + assert.deepEqual( + ranked.map((w) => w.key), + ["TomeOfTheEternalFlow", "SurfsUp", "PrototypeAmber"], + ); + }); +}); + +describe("rankSigWeaponsByGain", () => { + it("sorts and classifies by the stronger mean/median gain", () => { + const ranked = rankSigWeaponsByGain([ + { + key: "B", + teams: 1, + mean_pct_gain: 10, + median_pct_gain: 10, + min_pct_gain: 10, + max_pct_gain: 10, + }, + { + key: "A", + teams: 1, + mean_pct_gain: 10, + median_pct_gain: 10, + min_pct_gain: 10, + max_pct_gain: 10, + }, + { + key: "C", + teams: 1, + mean_pct_gain: 20, + median_pct_gain: 8, + min_pct_gain: 8, + max_pct_gain: 20, + }, + ]); + assert.deepEqual( + ranked.map((w) => [w.key, w.priority]), + [ + ["C", "exceptional"], + ["A", "solid"], + ["B", "solid"], + ], + ); + }); + + it("sorts non-finite gains last", () => { + const ranked = rankSigWeaponsByGain([ + { + key: "nan", + teams: 1, + mean_pct_gain: Number.NaN, + median_pct_gain: Number.NaN, + min_pct_gain: 0, + max_pct_gain: 0, + }, + { + key: "small", + teams: 1, + mean_pct_gain: 1, + median_pct_gain: 1, + min_pct_gain: 1, + max_pct_gain: 1, + }, + { + key: "infinite", + teams: 1, + mean_pct_gain: Number.POSITIVE_INFINITY, + median_pct_gain: 0, + min_pct_gain: 0, + max_pct_gain: 0, + }, + { + key: "big", + teams: 1, + mean_pct_gain: 24, + median_pct_gain: 24, + min_pct_gain: 24, + max_pct_gain: 24, + }, + ]); + assert.deepEqual( + ranked.map((w) => w.key), + ["big", "small", "infinite", "nan"], + ); + }); + + it("prefers merge-stamped tiers over the fixed ladder", () => { + const ranked = rankSigWeaponsByGain( + [ + { + key: "small_but_stamped", + teams: 1, + mean_pct_gain: 1, + median_pct_gain: 1, + min_pct_gain: 1, + max_pct_gain: 1, + tier: "exceptional", + }, + { + key: "big_unstamped", + teams: 1, + mean_pct_gain: 24, + median_pct_gain: 24, + min_pct_gain: 24, + max_pct_gain: 24, + }, + ], + { + floors: { + exceptional: 7.83, + high: 4.11, + solid: 0.79, + negligible: 0, + }, + labels: { + exceptional: "Exceptional impact", + high: "High impact", + solid: "Solid impact", + modest: "Modest impact", + negligible: "Negligible impact", + }, + }, + ); + assert.deepEqual( + ranked.map((w) => [w.key, w.priority, w.priorityLabel]), + [ + ["big_unstamped", "exceptional", "Exceptional impact"], + ["small_but_stamped", "exceptional", "Exceptional impact"], + ], + ); + }); +}); + +describe("constellationImpactRows", () => { + it("preserves source order and classifies each gain", () => { + const rows = constellationImpactRows([ + { + cons: 1, + teams: 2, + mean_pct_gain: 4, + median_pct_gain: 6, + min_pct_gain: 2, + max_pct_gain: 7, + }, + { + cons: 2, + teams: 2, + mean_pct_gain: 21, + median_pct_gain: 18, + min_pct_gain: 15, + max_pct_gain: 25, + }, + ]); + assert.deepEqual( + rows.map((row) => [row.cons, row.pct, row.priorityLabel]), + [ + [1, 6, "Modest impact"], + [2, 21, "Exceptional impact"], + ], + ); + }); + + it("classifies measured constellation gains on the five-band ladder", () => { + const rows = constellationImpactRows([ + { + cons: 1, + teams: 2, + mean_pct_gain: 30, + median_pct_gain: 30, + min_pct_gain: 30, + max_pct_gain: 30, + }, + ]); + assert.deepEqual( + rows.map((row) => [row.priority, row.priorityLabel]), + [["exceptional", "Exceptional impact"]], + ); + }); +}); + +describe("talentImportanceRows / levelImportanceFromBuilds", () => { + it("orders talent rows by max mean/median and classifies level impact", () => { + const rows = talentImportanceRows( + { + teams: 2, + auto: { + mean_pct_drop: 3, + median_pct_drop: 3, + min_pct_drop: 3, + max_pct_drop: 3, + }, + skill: { + mean_pct_drop: 12, + median_pct_drop: 12, + min_pct_drop: 12, + max_pct_drop: 12, + }, + burst: { + mean_pct_drop: 5, + median_pct_drop: 5, + min_pct_drop: 5, + max_pct_drop: 5, + }, + priority: ["skill", "burst", "auto"], + }, + (kitType) => `icon:${kitType}`, + ); + assert.deepEqual( + rows.map((r) => [r.slot, r.icon, r.priority]), + [ + ["skill", "icon:skill", "exceptional"], + ["burst", "icon:burst", "solid"], + ["auto", "icon:normal", "modest"], + ], + ); + + const level = levelImportanceFromBuilds( + builds({ + main_stats: { sands: [], goblet: [], circlet: [] }, + substat_rolls_liquid: { teams: 0, configs: 0, mean: {}, ranked: [] }, + level_importance: { + teams: 2, + mean_pct_drop: 6, + median_pct_drop: 6, + min_pct_drop: 6, + max_pct_drop: 6, + }, + }), + ); + assert.equal(level?.priority, "high"); + }); + + it("hides talent/level rows when teams are zero", () => { + assert.equal( + talentImportanceRows( + { + teams: 0, + auto: { + mean_pct_drop: 0, + median_pct_drop: 0, + min_pct_drop: 0, + max_pct_drop: 0, + }, + skill: { + mean_pct_drop: 0, + median_pct_drop: 0, + min_pct_drop: 0, + max_pct_drop: 0, + }, + burst: { + mean_pct_drop: 0, + median_pct_drop: 0, + min_pct_drop: 0, + max_pct_drop: 0, + }, + priority: ["auto", "skill", "burst"], + }, + () => null, + ).length, + 0, + ); + assert.equal( + levelImportanceFromBuilds( + builds({ + main_stats: { sands: [], goblet: [], circlet: [] }, + substat_rolls_liquid: { teams: 0, configs: 0, mean: {}, ranked: [] }, + level_importance: { + teams: 0, + mean_pct_drop: 0, + median_pct_drop: 0, + min_pct_drop: 0, + max_pct_drop: 0, + }, + }), + ), + null, + ); + }); +}); + +describe("guide vs sim section selection", () => { + const emptyShell = { + main_stats: { + sands: [], + goblet: [], + circlet: [], + } as CharacterIndex["main_stats"], + substat_rolls_liquid: { + teams: 0, + configs: 0, + mean: {}, + ranked: [], + }, + }; + + const simTalent: CharacterTalentImportance = { + teams: 2, + auto: { + mean_pct_drop: 3, + median_pct_drop: 3, + min_pct_drop: 3, + max_pct_drop: 3, + }, + skill: { + mean_pct_drop: 12, + median_pct_drop: 12, + min_pct_drop: 12, + max_pct_drop: 12, + }, + burst: { + mean_pct_drop: 5, + median_pct_drop: 5, + min_pct_drop: 5, + max_pct_drop: 5, + }, + priority: ["skill", "burst", "auto"], + }; + + it("fills missing sim sections from guide_priority by default", () => { + assert.equal(useGuideSection({ override: false }, true, false), true); + assert.equal(useGuideSection({ override: false }, true, true), false); + assert.equal(useGuideSection({ override: true }, true, true), true); + assert.equal(useGuideSection({ override: true }, false, true), false); + + const section = talentPrioritySection( + builds({ + ...emptyShell, + guide_priority: { + override: false, + talent_priority: ["burst", "skill", "auto"], + }, + }), + (kitType) => `icon:${kitType}`, + ); + assert.equal(section?.source, "guide"); + assert.deepEqual( + section?.source === "guide" + ? section.rows.map((r) => [r.slot, r.icon]) + : null, + [ + ["burst", "icon:burst"], + ["skill", "icon:skill"], + ["auto", "icon:normal"], + ], + ); + + assert.deepEqual( + levelPrioritySection( + builds({ + ...emptyShell, + guide_priority: { override: false, level_90: true }, + }), + ), + { + source: "guide", + simMissing: true, + priority: "solid", + priorityLabel: "Recommended", + }, + ); + + const cons = constellationPrioritySection( + builds({ + ...emptyShell, + guide_priority: { override: false, constellations: [2, 1] }, + }), + ); + assert.equal(cons?.source, "guide"); + assert.deepEqual( + cons?.source === "guide" + ? cons.rows.map((r) => [r.cons, r.priority, r.priorityLabel]) + : null, + [ + [1, "high", "High impact"], + [2, "high", "High impact"], + ], + ); + + const sigs = sigWeaponPrioritySection( + builds({ + ...emptyShell, + guide_priority: { + override: false, + sig_weapons: ["Elegy", "Skyward"], + }, + }), + ); + assert.equal(sigs?.source, "guide"); + assert.deepEqual( + sigs?.source === "guide" + ? sigs.rows.map((r) => [r.key, r.priority, r.priorityLabel]) + : null, + [ + ["Elegy", "high", "High impact"], + ["Skyward", "high", "High impact"], + ], + ); + }); + + it("marks guide sections that replace existing sim data as measured", () => { + const section = constellationPrioritySection( + builds({ + ...emptyShell, + vertical_importance: { + constellations: [ + { + cons: 1, + teams: 2, + mean_pct_gain: 20, + median_pct_gain: 20, + min_pct_gain: 20, + max_pct_gain: 20, + }, + ], + sig_weapons: [], + }, + guide_priority: { override: true, constellations: [2] }, + }), + ); + assert.equal(section?.source, "guide"); + assert.equal( + section?.source === "guide" ? section.simMissing : null, + false, + ); + }); + + it("keeps measured five-band rows when sim data exists and override is off", () => { + const section = talentPrioritySection( + builds({ + ...emptyShell, + talent_importance: simTalent, + guide_priority: { + override: false, + talent_priority: ["burst", "auto", "skill"], + }, + }), + () => null, + ); + assert.equal(section?.source, "sim"); + assert.deepEqual( + section?.source === "sim" + ? section.rows.map((r) => [r.slot, r.priority]) + : null, + [ + ["skill", "exceptional"], + ["burst", "solid"], + ["auto", "modest"], + ], + ); + }); + + it("replaces authored sections when override is true, leaving omitted sections on sim", () => { + const talent = talentPrioritySection( + builds({ + ...emptyShell, + talent_importance: simTalent, + level_importance: { + teams: 2, + mean_pct_drop: 6, + median_pct_drop: 6, + min_pct_drop: 6, + max_pct_drop: 6, + }, + guide_priority: { + override: true, + talent_priority: ["burst", "skill", "auto"], + // level_90 omitted → keep sim level + }, + }), + () => null, + ); + assert.equal(talent?.source, "guide"); + assert.deepEqual( + talent?.source === "guide" ? talent.rows.map((r) => r.slot) : null, + ["burst", "skill", "auto"], + ); + + const level = levelPrioritySection( + builds({ + ...emptyShell, + level_importance: { + teams: 2, + mean_pct_drop: 6, + median_pct_drop: 6, + min_pct_drop: 6, + max_pct_drop: 6, + }, + guide_priority: { + override: true, + talent_priority: ["burst", "skill", "auto"], + }, + }), + ); + assert.equal(level?.source, "sim"); + assert.equal(level?.source === "sim" ? level.row.priority : null, "high"); + }); +}); + +describe("reaction helpers", () => { + it("formats reaction names and fingerprints", () => { + assert.equal(formatReactionName("lunarcharged"), "Lunar-Charged"); + assert.equal(formatReactionName("swirl-electro"), "Swirl (Electro)"); + assert.equal(formatReactionFingerprint(null), "No reactions"); + assert.equal( + formatReactionFingerprint("bloom+swirl-hydro"), + "Bloom + Swirl (Hydro)", + ); + }); + +}); diff --git a/src/lib/character-builds.ts b/src/lib/character-builds.ts index 9c0641c..c6b9932 100644 --- a/src/lib/character-builds.ts +++ b/src/lib/character-builds.ts @@ -1,440 +1,520 @@ -/** - * Character Builds-tab display transforms. - * - * Pure helpers so the character page stays props → $derived → markup. - */ - -import { isArtifactSubstatKey } from "$lib/build-stats"; -import { - CONSTELLATION_UPGRADE, - LEVEL_UPGRADE, - SIGNATURE_UPGRADE, - TALENT_UPGRADE, - classifyUpgradeImpact, - impactForTier, - primaryUpgradePct, - type UpgradeImpactLadder, - type UpgradeTier, -} from "$lib/upgrade-priority"; -import type { - CharacterConsGain, - CharacterGuidePriority, - CharacterIndex, - CharacterSigGain, - CharacterTalentImportance, - CharacterVerticalGain, - CharacterWeaponRank, - TalentSlot, -} from "$lib/types/investment"; - -export const MAIN_STAT_SLOTS = [ - { key: "sands" as const, label: "Sands" }, - { key: "goblet" as const, label: "Goblet" }, - { key: "circlet" as const, label: "Circlet" }, -]; - -export const TALENT_SLOT_LABELS: Record = { - auto: "Normal", - skill: "Skill", - burst: "Burst", -}; - -/** Map investment talent slots → kit skill types for icons. */ -export const TALENT_SLOT_TO_KIT: Record = { - auto: "normal", - skill: "skill", - burst: "burst", -}; - -export type RecommendedSubstat = { - key: string; - mean: number; - matchesMain: boolean; - mainSlots: string[]; -}; - -/** - * Recommended substats from OptimFull liquid ranks, plus any recommended - * main that can also roll as a substat (e.g. EM sands → also recommend EM - * subs). Main-only keys (elemental DMG, heal, etc.) stay excluded. - * - * Guide merges may fill `ranked` with mean 0 when there are no measured - * teams — keep those editorial ranks (the 0.5 floor is for noisy sim data). - */ -export function recommendedSubstatsFromBuilds( - builds: CharacterIndex | null | undefined, -): RecommendedSubstat[] { - if (!builds) return []; - const mainSlots = new Map(); - for (const slot of MAIN_STAT_SLOTS) { - for (const s of builds.main_stats[slot.key]) { - if (!isArtifactSubstatKey(s.key)) continue; - const list = mainSlots.get(s.key) ?? []; - list.push(slot.label); - mainSlots.set(s.key, list); - } - } - - const byKey = new Map(); - const guideAuthoredSubs = builds.substat_rolls_liquid.teams <= 0; - - for (const r of builds.substat_rolls_liquid.ranked) { - if (!isArtifactSubstatKey(r.key)) continue; - if ( - !guideAuthoredSubs && - r.mean <= 0.5 && - !mainSlots.has(r.key) - ) { - continue; - } - const slots = mainSlots.get(r.key) ?? []; - byKey.set(r.key, { - key: r.key, - mean: r.mean, - matchesMain: slots.length > 0, - mainSlots: slots, - }); - } - - for (const [key, slots] of mainSlots) { - if (byKey.has(key)) continue; - byKey.set(key, { - key, - mean: 0, - matchesMain: true, - mainSlots: slots, - }); - } - - return [...byKey.values()].sort((a, b) => { - if (a.matchesMain !== b.matchesMain) return a.matchesMain ? -1 : 1; - if (a.mean !== b.mean) return b.mean - a.mean; - return a.key.localeCompare(b.key); - }); -} - -/** Weapons: higher rarity first, then team usage (stable within ties). */ -export function rankWeaponsByRarityAndTeams( - weapons: CharacterWeaponRank[] | null | undefined, - getStars: (key: string) => number, -): CharacterWeaponRank[] { - if (!weapons?.length) return []; - return [...weapons].sort((a, b) => { - const ra = getStars(a.key); - const rb = getStars(b.key); - if (ra !== rb) return rb - ra; - return b.teams - a.teams; - }); -} - -export type VerticalImpactRow = T & { - pct: number; - priority: UpgradeTier; - priorityLabel: string; -}; - -/** Add the primary pct and its resolved impact to a vertical gain row. */ -function attachImpact( - row: T, - ladder: UpgradeImpactLadder, -): VerticalImpactRow { - const pct = primaryUpgradePct(row.mean_pct_gain, row.median_pct_gain); - const impact = classifyUpgradeImpact(pct, ladder); - return { - ...row, - pct, - priority: impact.tier, - priorityLabel: impact.label, - }; -} - -/** - * Descending impact, with non-finite pct treated as last place. Subtracting - * raw values would yield NaN and leave those rows in arbitrary positions. - */ -function compareByImpact(a: { pct: number }, b: { pct: number }): number { - const aOk = Number.isFinite(a.pct); - const bOk = Number.isFinite(b.pct); - if (!aOk || !bOk) return aOk === bOk ? 0 : aOk ? -1 : 1; - return b.pct - a.pct; -} - -/** Constellations in source order with their display-ready impact. */ -export function constellationImpactRows( - constellations: CharacterConsGain[] | null | undefined, -): VerticalImpactRow[] { - if (!constellations?.length) return []; - return constellations.map((row) => attachImpact(row, CONSTELLATION_UPGRADE)); -} - -/** Signature weapons ranked by primary gain, with display-ready impact. */ -export function rankSigWeaponsByGain( - sigWeapons: CharacterSigGain[] | null | undefined, -): VerticalImpactRow[] { - if (!sigWeapons?.length) return []; - return sigWeapons - .map((row) => attachImpact(row, SIGNATURE_UPGRADE)) - .sort((a, b) => compareByImpact(a, b) || a.key.localeCompare(b.key)); -} - -export type TalentImportanceRow = { - slot: TalentSlot; - label: string; - icon: string | null; - priority: UpgradeTier; - priorityLabel: string; - pct: number; - mean: number; - median: number; - min: number; - max: number; - teams: number; -}; - -/** - * Talent priority rows from measured simulation data. Qualitative labels from - * max(mean, median) % DPS drop when that talent is at 1. - */ -export function talentImportanceRows( - talentImportance: CharacterTalentImportance | null | undefined, - resolveSkillIcon: (kitType: string) => string | null, -): TalentImportanceRow[] { - if (!talentImportance || talentImportance.teams <= 0) return []; - - const slots = ["auto", "skill", "burst"] as const; - const rows = slots.flatMap((slot) => { - const stats = talentImportance[slot]; - if (!stats) return []; - const pct = primaryUpgradePct(stats.mean_pct_drop, stats.median_pct_drop); - const impact = classifyUpgradeImpact(pct, TALENT_UPGRADE); - return [ - { - slot, - label: TALENT_SLOT_LABELS[slot], - icon: resolveSkillIcon(TALENT_SLOT_TO_KIT[slot]), - priority: impact.tier, - priorityLabel: impact.label, - pct, - mean: stats.mean_pct_drop, - median: stats.median_pct_drop, - min: stats.min_pct_drop, - max: stats.max_pct_drop, - teams: talentImportance.teams, - }, - ]; - }); - - return rows.sort( - (a, b) => compareByImpact(a, b) || a.slot.localeCompare(b.slot), - ); -} - -export type LevelImportanceRow = { - priority: UpgradeTier; - priorityLabel: string; - teams: number; - mean: number; - median: number; - min: number; - max: number; -}; - -/** Character level 90 importance from measured simulation data. */ -export function levelImportanceFromBuilds( - builds: CharacterIndex | null | undefined, -): LevelImportanceRow | null { - const li = builds?.level_importance; - if (!li || li.teams <= 0) return null; - const pct = primaryUpgradePct(li.mean_pct_drop, li.median_pct_drop); - const impact = classifyUpgradeImpact(pct, LEVEL_UPGRADE); - return { - priority: impact.tier, - priorityLabel: impact.label, - teams: li.teams, - mean: li.mean_pct_drop, - median: li.median_pct_drop, - min: li.min_pct_drop, - max: li.max_pct_drop, - }; -} - -// ── Guide vs sim section selection ────────────────────────────────────────── - -export type BuildsSectionSource = "sim" | "guide"; - -export type GuideTalentRow = { - slot: TalentSlot; - label: string; - icon: string | null; -}; - -export type GuideConsRow = { - cons: number; - priority: UpgradeTier; - priorityLabel: string; -}; - -export type GuideSigRow = { - key: string; - priority: UpgradeTier; - priorityLabel: string; -}; - -/** - * A guide only lists a constellation or signature when it is worth pulling for, - * so those rows present at the vertical ladder's high band — the recommendation - * itself is the claim, and no percentage stands behind it. - */ -const GUIDE_VERTICAL_IMPACT = impactForTier(CONSTELLATION_UPGRADE, "high"); - -/** Guide Level 90 recommendation borrows the level ladder's "Recommended" band. */ -const GUIDE_LEVEL_IMPACT = impactForTier(LEVEL_UPGRADE, "solid"); - -/** - * Whether an authored guide section should present instead of measured data. - * Default fills gaps; override replaces only sections the guide authored. - */ -export function useGuideSection( - guide: CharacterGuidePriority | null | undefined, - hasGuide: boolean, - hasSim: boolean, -): boolean { - if (!guide || !hasGuide) return false; - if (guide.override) return true; - return !hasSim; -} - -export type TalentPrioritySection = - | { source: "sim"; rows: TalentImportanceRow[] } - | { source: "guide"; simMissing: boolean; rows: GuideTalentRow[] }; - -export function talentPrioritySection( - builds: CharacterIndex | null | undefined, - resolveSkillIcon: (kitType: string) => string | null, -): TalentPrioritySection | null { - const guide = builds?.guide_priority; - const simRows = talentImportanceRows( - builds?.talent_importance, - resolveSkillIcon, - ); - const guideSlots = (guide?.talent_priority ?? []).filter( - (slot): slot is TalentSlot => - Object.hasOwn(TALENT_SLOT_LABELS, slot) && - Object.hasOwn(TALENT_SLOT_TO_KIT, slot), - ); - const preferGuide = useGuideSection( - guide, - guideSlots.length > 0, - simRows.length > 0, - ); - if (preferGuide) { - return { - source: "guide", - simMissing: simRows.length === 0, - rows: guideSlots.map((slot) => ({ - slot, - label: TALENT_SLOT_LABELS[slot], - icon: resolveSkillIcon(TALENT_SLOT_TO_KIT[slot]), - })), - }; - } - if (simRows.length > 0) return { source: "sim", rows: simRows }; - return null; -} - -export type LevelPrioritySection = - | { source: "sim"; row: LevelImportanceRow } - | { - source: "guide"; - simMissing: boolean; - priority: UpgradeTier; - priorityLabel: string; - }; - -export function levelPrioritySection( - builds: CharacterIndex | null | undefined, -): LevelPrioritySection | null { - const guide = builds?.guide_priority; - const simRow = levelImportanceFromBuilds(builds); - const preferGuide = useGuideSection( - guide, - guide?.level_90 === true, - simRow != null, - ); - if (preferGuide) { - return { - source: "guide", - simMissing: simRow == null, - priority: GUIDE_LEVEL_IMPACT.tier, - priorityLabel: GUIDE_LEVEL_IMPACT.label, - }; - } - if (simRow) return { source: "sim", row: simRow }; - return null; -} - -export type ConsPrioritySection = - | { source: "sim"; rows: VerticalImpactRow[] } - | { source: "guide"; simMissing: boolean; rows: GuideConsRow[] }; - -export function constellationPrioritySection( - builds: CharacterIndex | null | undefined, -): ConsPrioritySection | null { - const guide = builds?.guide_priority; - const simRows = constellationImpactRows( - builds?.vertical_importance?.constellations, - ); - const guideCons = guide?.constellations ?? []; - const preferGuide = useGuideSection( - guide, - guideCons.length > 0, - simRows.length > 0, - ); - if (preferGuide) { - return { - source: "guide", - simMissing: simRows.length === 0, - rows: [...guideCons] - .sort((a, b) => a - b) - .map((cons) => ({ - cons, - priority: GUIDE_VERTICAL_IMPACT.tier, - priorityLabel: GUIDE_VERTICAL_IMPACT.label, - })), - }; - } - if (simRows.length > 0) return { source: "sim", rows: simRows }; - return null; -} - -export type SigPrioritySection = - | { source: "sim"; rows: VerticalImpactRow[] } - | { source: "guide"; simMissing: boolean; rows: GuideSigRow[] }; - -export function sigWeaponPrioritySection( - builds: CharacterIndex | null | undefined, -): SigPrioritySection | null { - const guide = builds?.guide_priority; - const simRows = rankSigWeaponsByGain( - builds?.vertical_importance?.sig_weapons, - ); - const guideSigs = guide?.sig_weapons ?? []; - const preferGuide = useGuideSection( - guide, - guideSigs.length > 0, - simRows.length > 0, - ); - if (preferGuide) { - return { - source: "guide", - simMissing: simRows.length === 0, - rows: guideSigs.map((key) => ({ - key, - priority: GUIDE_VERTICAL_IMPACT.tier, - priorityLabel: GUIDE_VERTICAL_IMPACT.label, - })), - }; - } - if (simRows.length > 0) return { source: "sim", rows: simRows }; - return null; -} +/** + * Character Builds-tab display transforms. + * + * Pure helpers so the character page stays props → $derived → markup. + */ + +import { isArtifactSubstatKey } from "$lib/build-stats"; +import { translateStatKey } from "$lib/utils"; +import { + CONSTELLATION_UPGRADE, + LEVEL_UPGRADE, + SIGNATURE_UPGRADE, + TALENT_UPGRADE, + classifyUpgradeImpact, + impactForTier, + primaryUpgradePct, + resolveUpgradeImpact, + type UpgradeImpactLadder, + type UpgradeTier, +} from "$lib/upgrade-priority"; +import type { + CharacterConsGain, + CharacterGuidePriority, + CharacterIndex, + CharacterSigGain, + CharacterTalentImportance, + CharacterVerticalGain, + CharacterWeaponRank, + ImpactTierScale, + TalentSlot, +} from "$lib/types/investment"; + +export const MAIN_STAT_SLOTS = [ + { key: "sands" as const, label: "Sands" }, + { key: "goblet" as const, label: "Goblet" }, + { key: "circlet" as const, label: "Circlet" }, +]; + +export const TALENT_SLOT_LABELS: Record = { + auto: "Normal", + skill: "Skill", + burst: "Burst", +}; + +/** Map investment talent slots → kit skill types for icons. */ +export const TALENT_SLOT_TO_KIT: Record = { + auto: "normal", + skill: "skill", + burst: "burst", +}; + +export type RecommendedSubstat = { + key: string; + mean: number; + matchesMain: boolean; + mainSlots: string[]; +}; + +/** + * Recommended substats from OptimFull liquid ranks, plus any recommended + * main that can also roll as a substat (e.g. EM sands → also recommend EM + * subs). Main-only keys (elemental DMG, heal, etc.) stay excluded. + * + * Guide merges may fill `ranked` with mean 0 when there are no measured + * teams — keep those editorial ranks (the 0.5 floor is for noisy sim data). + */ +export function recommendedSubstatsFromBuilds( + builds: CharacterIndex | null | undefined, +): RecommendedSubstat[] { + if (!builds) return []; + const mainSlots = new Map(); + for (const slot of MAIN_STAT_SLOTS) { + for (const s of builds.main_stats[slot.key]) { + if (!isArtifactSubstatKey(s.key)) continue; + const list = mainSlots.get(s.key) ?? []; + list.push(slot.label); + mainSlots.set(s.key, list); + } + } + + const byKey = new Map(); + const guideAuthoredSubs = builds.substat_rolls_liquid.teams <= 0; + + for (const r of builds.substat_rolls_liquid.ranked) { + if (!isArtifactSubstatKey(r.key)) continue; + if ( + !guideAuthoredSubs && + r.mean <= 0.5 && + !mainSlots.has(r.key) + ) { + continue; + } + const slots = mainSlots.get(r.key) ?? []; + byKey.set(r.key, { + key: r.key, + mean: r.mean, + matchesMain: slots.length > 0, + mainSlots: slots, + }); + } + + for (const [key, slots] of mainSlots) { + if (byKey.has(key)) continue; + byKey.set(key, { + key, + mean: 0, + matchesMain: true, + mainSlots: slots, + }); + } + + return [...byKey.values()].sort((a, b) => { + if (a.matchesMain !== b.matchesMain) return a.matchesMain ? -1 : 1; + if (a.mean !== b.mean) return b.mean - a.mean; + return a.key.localeCompare(b.key); + }); +} + +/** Weapons: rarity → BT strength → teams → measured sigs → name. */ +export function rankWeaponsByRarityAndTeams( + weapons: CharacterWeaponRank[] | null | undefined, + getStars: (key: string) => number, + preferredKeys?: ReadonlySet | readonly string[] | null, +): CharacterWeaponRank[] { + if (!weapons?.length) return []; + const preferred = + preferredKeys instanceof Set + ? preferredKeys + : new Set(preferredKeys ?? []); + return [...weapons].sort((a, b) => { + const ra = getStars(a.key); + const rb = getStars(b.key); + if (ra !== rb) return rb - ra; + const sa = a.strength ?? 0; + const sb = b.strength ?? 0; + if (sa !== sb) return sb - sa; + if (a.teams !== b.teams) return b.teams - a.teams; + const pa = preferred.has(a.key) ? 0 : 1; + const pb = preferred.has(b.key) ? 0 : 1; + if (pa !== pb) return pa - pb; + return a.key.localeCompare(b.key); + }); +} + +export type VerticalImpactRow = T & { + pct: number; + priority: UpgradeTier; + priorityLabel: string; +}; + +/** Add the primary pct and its resolved impact to a vertical gain row. */ +function attachImpact( + row: T, + ladder: UpgradeImpactLadder, + scale?: ImpactTierScale | null, +): VerticalImpactRow { + const pct = primaryUpgradePct(row.mean_pct_gain, row.median_pct_gain); + const impact = resolveUpgradeImpact(row.tier, pct, ladder, scale); + return { + ...row, + pct, + priority: impact.tier, + priorityLabel: impact.label, + }; +} + +/** + * Descending impact, with non-finite pct treated as last place. Subtracting + * raw values would yield NaN and leave those rows in arbitrary positions. + */ +function compareByImpact(a: { pct: number }, b: { pct: number }): number { + const aOk = Number.isFinite(a.pct); + const bOk = Number.isFinite(b.pct); + if (!aOk || !bOk) return aOk === bOk ? 0 : aOk ? -1 : 1; + return b.pct - a.pct; +} + +function impactScale( + builds: CharacterIndex | null | undefined, + key: "talents" | "constellations" | "sig_weapons", +): ImpactTierScale | null | undefined { + return builds?.impact_tiers?.[key]; +} + +/** Constellations in source order with their display-ready impact. */ +export function constellationImpactRows( + constellations: CharacterConsGain[] | null | undefined, + scale?: ImpactTierScale | null, +): VerticalImpactRow[] { + if (!constellations?.length) return []; + return constellations.map((row) => + attachImpact(row, CONSTELLATION_UPGRADE, scale), + ); +} + +/** Signature weapons ranked by primary gain, with display-ready impact. */ +export function rankSigWeaponsByGain( + sigWeapons: CharacterSigGain[] | null | undefined, + scale?: ImpactTierScale | null, +): VerticalImpactRow[] { + if (!sigWeapons?.length) return []; + return sigWeapons + .map((row) => attachImpact(row, SIGNATURE_UPGRADE, scale)) + .sort((a, b) => compareByImpact(a, b) || a.key.localeCompare(b.key)); +} + +export type TalentImportanceRow = { + slot: TalentSlot; + label: string; + icon: string | null; + priority: UpgradeTier; + priorityLabel: string; + pct: number; + mean: number; + median: number; + min: number; + max: number; + teams: number; +}; + +/** + * Talent priority rows from measured simulation data. Prefer merge-stamped + * impact tiers; fall back to the talent ladder when `tier` is absent. + */ +export function talentImportanceRows( + talentImportance: CharacterTalentImportance | null | undefined, + resolveSkillIcon: (kitType: string) => string | null, + scale?: ImpactTierScale | null, +): TalentImportanceRow[] { + if (!talentImportance || talentImportance.teams <= 0) return []; + + const slots = ["auto", "skill", "burst"] as const; + const rows = slots.flatMap((slot) => { + const stats = talentImportance[slot]; + if (!stats) return []; + const pct = primaryUpgradePct(stats.mean_pct_drop, stats.median_pct_drop); + const impact = resolveUpgradeImpact(stats.tier, pct, TALENT_UPGRADE, scale); + return [ + { + slot, + label: TALENT_SLOT_LABELS[slot], + icon: resolveSkillIcon(TALENT_SLOT_TO_KIT[slot]), + priority: impact.tier, + priorityLabel: impact.label, + pct, + mean: stats.mean_pct_drop, + median: stats.median_pct_drop, + min: stats.min_pct_drop, + max: stats.max_pct_drop, + teams: talentImportance.teams, + }, + ]; + }); + + return rows.sort( + (a, b) => compareByImpact(a, b) || a.slot.localeCompare(b.slot), + ); +} + +export type LevelImportanceRow = { + priority: UpgradeTier; + priorityLabel: string; + teams: number; + mean: number; + median: number; + min: number; + max: number; +}; + +/** Character level 90 importance from measured simulation data. */ +export function levelImportanceFromBuilds( + builds: CharacterIndex | null | undefined, +): LevelImportanceRow | null { + const li = builds?.level_importance; + if (!li || li.teams <= 0) return null; + const pct = primaryUpgradePct(li.mean_pct_drop, li.median_pct_drop); + const impact = classifyUpgradeImpact(pct, LEVEL_UPGRADE); + return { + priority: impact.tier, + priorityLabel: impact.label, + teams: li.teams, + mean: li.mean_pct_drop, + median: li.median_pct_drop, + min: li.min_pct_drop, + max: li.max_pct_drop, + }; +} + +// ── Guide vs sim section selection ────────────────────────────────────────── + +export type BuildsSectionSource = "sim" | "guide"; + +export type GuideTalentRow = { + slot: TalentSlot; + label: string; + icon: string | null; +}; + +export type GuideConsRow = { + cons: number; + priority: UpgradeTier; + priorityLabel: string; +}; + +export type GuideSigRow = { + key: string; + priority: UpgradeTier; + priorityLabel: string; +}; + +/** + * A guide only lists a constellation or signature when it is worth pulling for, + * so those rows present at the vertical ladder's high band — the recommendation + * itself is the claim, and no percentage stands behind it. + */ +const GUIDE_VERTICAL_IMPACT = impactForTier(CONSTELLATION_UPGRADE, "high"); + +/** Guide Level 90 recommendation borrows the level ladder's "Recommended" band. */ +const GUIDE_LEVEL_IMPACT = impactForTier(LEVEL_UPGRADE, "solid"); + +/** + * Whether an authored guide section should present instead of measured data. + * Default fills gaps; override replaces only sections the guide authored. + */ +export function useGuideSection( + guide: CharacterGuidePriority | null | undefined, + hasGuide: boolean, + hasSim: boolean, +): boolean { + if (!guide || !hasGuide) return false; + if (guide.override) return true; + return !hasSim; +} + +export type TalentPrioritySection = + | { source: "sim"; rows: TalentImportanceRow[] } + | { source: "guide"; simMissing: boolean; rows: GuideTalentRow[] }; + +export function talentPrioritySection( + builds: CharacterIndex | null | undefined, + resolveSkillIcon: (kitType: string) => string | null, +): TalentPrioritySection | null { + const guide = builds?.guide_priority; + const simRows = talentImportanceRows( + builds?.talent_importance, + resolveSkillIcon, + impactScale(builds, "talents"), + ); + const guideSlots = (guide?.talent_priority ?? []).filter( + (slot): slot is TalentSlot => + Object.hasOwn(TALENT_SLOT_LABELS, slot) && + Object.hasOwn(TALENT_SLOT_TO_KIT, slot), + ); + const preferGuide = useGuideSection( + guide, + guideSlots.length > 0, + simRows.length > 0, + ); + if (preferGuide) { + return { + source: "guide", + simMissing: simRows.length === 0, + rows: guideSlots.map((slot) => ({ + slot, + label: TALENT_SLOT_LABELS[slot], + icon: resolveSkillIcon(TALENT_SLOT_TO_KIT[slot]), + })), + }; + } + if (simRows.length > 0) return { source: "sim", rows: simRows }; + return null; +} + +export type LevelPrioritySection = + | { source: "sim"; row: LevelImportanceRow } + | { + source: "guide"; + simMissing: boolean; + priority: UpgradeTier; + priorityLabel: string; + }; + +export function levelPrioritySection( + builds: CharacterIndex | null | undefined, +): LevelPrioritySection | null { + const guide = builds?.guide_priority; + const simRow = levelImportanceFromBuilds(builds); + const preferGuide = useGuideSection( + guide, + guide?.level_90 === true, + simRow != null, + ); + if (preferGuide) { + return { + source: "guide", + simMissing: simRow == null, + priority: GUIDE_LEVEL_IMPACT.tier, + priorityLabel: GUIDE_LEVEL_IMPACT.label, + }; + } + if (simRow) return { source: "sim", row: simRow }; + return null; +} + +export type ConsPrioritySection = + | { source: "sim"; rows: VerticalImpactRow[] } + | { source: "guide"; simMissing: boolean; rows: GuideConsRow[] }; + +export function constellationPrioritySection( + builds: CharacterIndex | null | undefined, +): ConsPrioritySection | null { + const guide = builds?.guide_priority; + const simRows = constellationImpactRows( + builds?.vertical_importance?.constellations, + impactScale(builds, "constellations"), + ); + const guideCons = guide?.constellations ?? []; + const preferGuide = useGuideSection( + guide, + guideCons.length > 0, + simRows.length > 0, + ); + if (preferGuide) { + return { + source: "guide", + simMissing: simRows.length === 0, + rows: [...guideCons] + .sort((a, b) => a - b) + .map((cons) => ({ + cons, + priority: GUIDE_VERTICAL_IMPACT.tier, + priorityLabel: GUIDE_VERTICAL_IMPACT.label, + })), + }; + } + if (simRows.length > 0) return { source: "sim", rows: simRows }; + return null; +} + +export type SigPrioritySection = + | { source: "sim"; rows: VerticalImpactRow[] } + | { source: "guide"; simMissing: boolean; rows: GuideSigRow[] }; + +export function sigWeaponPrioritySection( + builds: CharacterIndex | null | undefined, +): SigPrioritySection | null { + const guide = builds?.guide_priority; + const simRows = rankSigWeaponsByGain( + builds?.vertical_importance?.sig_weapons, + impactScale(builds, "sig_weapons"), + ); + const guideSigs = guide?.sig_weapons ?? []; + const preferGuide = useGuideSection( + guide, + guideSigs.length > 0, + simRows.length > 0, + ); + if (preferGuide) { + return { + source: "guide", + simMissing: simRows.length === 0, + rows: guideSigs.map((key) => ({ + key, + priority: GUIDE_VERTICAL_IMPACT.tier, + priorityLabel: GUIDE_VERTICAL_IMPACT.label, + })), + }; + } + if (simRows.length > 0) return { source: "sim", rows: simRows }; + return null; +} + +const REACTION_LABELS: Record = { + melt: "Melt", + vaporize: "Vaporize", + overload: "Overload", + electrocharged: "Electro-Charged", + superconduct: "Superconduct", + freeze: "Freeze", + shatter: "Shatter", + bloom: "Bloom", + hyperbloom: "Hyperbloom", + burgeon: "Burgeon", + burning: "Burning", + spread: "Spread", + aggravate: "Aggravate", + quicken: "Quicken", + lunarcharged: "Lunar-Charged", + swirl: "Swirl", + "swirl-pyro": "Swirl (Pyro)", + "swirl-hydro": "Swirl (Hydro)", + "swirl-electro": "Swirl (Electro)", + "swirl-cryo": "Swirl (Cryo)", + "swirl-anemo": "Swirl (Anemo)", + "swirl-dendro": "Swirl (Dendro)", + "swirl-geo": "Swirl (Geo)", +}; + +/** Display label for a single reaction bucket name. */ +export function formatReactionName(name: string): string { + if (!name) return name; + const known = REACTION_LABELS[name.toLowerCase()]; + if (known) return known; + return name + .split(/[-_]/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +/** Fingerprint like ``melt+vaporize`` → ``Melt + Vaporize``. */ +export function formatReactionFingerprint( + fingerprint: string | null | undefined, +): string { + if (!fingerprint) return "No reactions"; + return fingerprint + .split("+") + .map((part) => formatReactionName(part.trim())) + .join(" + "); +} diff --git a/src/lib/investment-build-card.ts b/src/lib/investment-build-card.ts new file mode 100644 index 0000000..e36379e --- /dev/null +++ b/src/lib/investment-build-card.ts @@ -0,0 +1,36 @@ +import { skillIconUrl, talentIconUrl } from "$lib/asset-urls"; +import type { CharacterKit } from "$lib/types/character-kit"; + +/** Kit icon strip passed into InvestmentBuildCard. */ +export type InvestmentBuildKitIcons = { + constellations: { index: number; name: string; icon: string | null }[]; + talents: { + auto: string | null; + skill: string | null; + burst: string | null; + }; +}; + +/** Map a CDN kit into the icon strip InvestmentBuildCard expects. */ +export function kitIconsFromCharacterKit( + kit: Pick, +): InvestmentBuildKitIcons { + // Skills use UI_SkillIcon_*; constellations / passives use UI_Talent_*. + const byType = Object.fromEntries( + kit.skills.map((s) => [s.type, skillIconUrl(s.icon)]), + ); + return { + constellations: [...kit.constellations] + .sort((a, b) => a.index - b.index) + .map((c) => ({ + index: c.index, + name: c.name, + icon: talentIconUrl(c.icon), + })), + talents: { + auto: byType.normal ?? null, + skill: byType.skill ?? null, + burst: byType.burst ?? null, + }, + }; +} diff --git a/src/lib/investment-teams.test.ts b/src/lib/investment-teams.test.ts index 9fbd95a..4b2cbf7 100644 --- a/src/lib/investment-teams.test.ts +++ b/src/lib/investment-teams.test.ts @@ -10,6 +10,7 @@ import { availableInvestmentCosts, baselineSim, baselineVariants, + ownedVariants, displayDps, displaySim, exactCostDps, @@ -67,13 +68,15 @@ describe("baselineSim / simAtExactCost", () => { }); }); -describe("baselineVariants / groupVerticalSimsByCost", () => { - it("lists floor variants by DPS and groups verticals by cost", () => { +describe("baselineVariants / ownedVariants / groupVerticalSimsByCost", () => { + it("lists floor variants and owned alts separately; groups verticals by cost", () => { const t = team( ["A"], [ sim({ cost: 2, dps: 100, kind: "f2p" }), sim({ cost: 2, dps: 150, kind: "baseline" }), + sim({ cost: 3, dps: 180, kind: "owned" }), + sim({ cost: 3, dps: 160, kind: "owned" }), sim({ cost: 4, dps: 200, kind: "vertical" }), sim({ cost: 4, dps: 250, kind: "vertical" }), sim({ cost: 6, dps: 300, kind: "vertical" }), @@ -83,6 +86,10 @@ describe("baselineVariants / groupVerticalSimsByCost", () => { baselineVariants(t).map((r) => r.dps), [150, 100], ); + assert.deepEqual( + ownedVariants(t).map((r) => r.dps), + [180, 160], + ); const groups = groupVerticalSimsByCost(t); assert.deepEqual( groups.map((g) => [g.cost, g.sims.map((s) => s.dps)]), @@ -110,6 +117,35 @@ describe("exactCostDps", () => { assert.equal(exactCostDps(team(["A"], []), 4), 0); }); + it("skips owned sims so they do not win cost-filter peaks", () => { + const t = team( + ["A"], + [ + sim({ cost: 2, dps: 100, kind: "baseline" }), + sim({ cost: 3, dps: 400, kind: "owned" }), + sim({ cost: 3, dps: 250, kind: "vertical" }), + ], + ); + assert.equal(exactCostDps(t, 3), 250); + assert.equal(simAtExactCost(t, 3)?.kind, "vertical"); + assert.equal(simAtExactCost(t, 3)?.dps, 250); + }); + + it("excludes costs that only exist on owned sims from cost filters", () => { + const t = team( + ["A"], + [ + sim({ cost: 2, dps: 100, kind: "baseline" }), + sim({ cost: 3, dps: 400, kind: "owned" }), + ], + ); + assert.deepEqual( + availableInvestmentCosts({ teams: [t], available_costs: [] }), + [2], + ); + assert.deepEqual(teamsWithExactCost([t], 3), []); + }); + it("returns the best exact-cost result and never falls back", () => { const t = team( ["A"], diff --git a/src/lib/investment-teams.ts b/src/lib/investment-teams.ts index aa6775a..e1adb3b 100644 --- a/src/lib/investment-teams.ts +++ b/src/lib/investment-teams.ts @@ -26,7 +26,7 @@ export function baselineSim(team: InvestmentTeam): InvestmentSim | null { return team.results.find((r) => r.kind === "baseline") ?? null; } -/** Floor-cost alternatives (baseline + f2p), highest DPS first. */ +/** Floor alternatives (baseline / f2p), highest DPS first. */ export function baselineVariants(team: InvestmentTeam): InvestmentSim[] { return team.results .filter((r) => r.kind === "baseline" || r.kind === "f2p") @@ -34,6 +34,14 @@ export function baselineVariants(team: InvestmentTeam): InvestmentSim[] { .sort((a, b) => b.dps - a.dps); } +/** Already-owned weapon alts (+1 cost), highest DPS first. */ +export function ownedVariants(team: InvestmentTeam): InvestmentSim[] { + return team.results + .filter((r) => r.kind === "owned") + .slice() + .sort((a, b) => b.dps - a.dps); +} + export type VerticalCostGroup = { cost: number; sims: InvestmentSim[]; @@ -58,18 +66,21 @@ export function groupVerticalSimsByCost( .map(([cost, sims]) => ({ cost, sims })); } -/** First sim at exactly `cost`, or null. */ +/** First non-owned sim at exactly `cost`, or null. */ export function simAtExactCost( team: InvestmentTeam, cost: number, ): InvestmentSim | null { - return team.results.find((r) => r.cost === cost) ?? null; + return ( + team.results.find((r) => r.cost === cost && r.kind !== "owned") ?? null + ); } -/** Best DPS at exactly `cost`, or 0 when no result matches. */ +/** Best DPS at exactly `cost` (skips owned), or 0 when no result matches. */ export function exactCostDps(team: InvestmentTeam, cost: number): number { let bestDps = 0; for (const result of team.results) { + if (result.kind === "owned") continue; if (result.cost === cost) { bestDps = Math.max(bestDps, result.dps); } @@ -112,12 +123,14 @@ export function teamsMatchingTags( return teams.filter((t) => tags.every((tag) => t.characters.includes(tag))); } -/** Teams that have at least one sim at exactly `cost`. */ +/** Teams that have at least one non-owned sim at exactly `cost`. */ export function teamsWithExactCost( teams: InvestmentTeam[], cost: number, ): InvestmentTeam[] { - return teams.filter((t) => t.results.some((r) => r.cost === cost)); + return teams.filter((t) => + t.results.some((r) => r.cost === cost && r.kind !== "owned"), + ); } /** Unique costs across teams (prefer merge-time list; else scan). */ @@ -127,7 +140,10 @@ export function availableInvestmentCosts( if (data.available_costs?.length) return data.available_costs; const set = new Set(); for (const t of data.teams) { - for (const r of t.results) set.add(r.cost); + for (const r of t.results) { + if (r.kind === "owned") continue; + set.add(r.cost); + } } return [...set].sort((a, b) => a - b); } diff --git a/src/lib/patch-notes-catalog.ts b/src/lib/patch-notes-catalog.ts new file mode 100644 index 0000000..f75bea4 --- /dev/null +++ b/src/lib/patch-notes-catalog.ts @@ -0,0 +1,38 @@ +/** + * Bundled patch-note catalog (Vite eager raw imports of repo-root markdown). + */ +import { + isPatchNoteFilename, + parsePatchNoteMarkdown, + type PatchNote, +} from "$lib/patch-notes"; + +const rawModules = import.meta.glob("../../patch-notes/*.md", { + eager: true, + query: "?raw", + import: "default", +}) as Record; + +function loadAllNotes(): PatchNote[] { + const notes: PatchNote[] = []; + for (const [path, raw] of Object.entries(rawModules)) { + if (!isPatchNoteFilename(path)) continue; + notes.push(parsePatchNoteMarkdown(path, raw)); + } + notes.sort((a, b) => { + if (a.date !== b.date) return a.date < b.date ? 1 : -1; + return a.slug < b.slug ? 1 : a.slug > b.slug ? -1 : 0; + }); + return notes; +} + +let cached: PatchNote[] | null = null; + +export function listPatchNotes(): PatchNote[] { + cached ??= loadAllNotes(); + return cached; +} + +export function getPatchNote(slug: string): PatchNote | undefined { + return listPatchNotes().find((n) => n.slug === slug); +} diff --git a/src/lib/patch-notes-seen.test.ts b/src/lib/patch-notes-seen.test.ts new file mode 100644 index 0000000..39a1e85 --- /dev/null +++ b/src/lib/patch-notes-seen.test.ts @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { shouldShowPatchNotesPopup } from "./patch-notes-seen.ts"; + +describe("patch notes seen", () => { + it("shows when nothing has been seen yet", () => { + assert.equal( + shouldShowPatchNotesPopup("2026-08-10-roster-hotfix", null), + true, + ); + }); + + it("shows when a newer slug exists", () => { + assert.equal( + shouldShowPatchNotesPopup( + "2026-08-11-next", + "2026-08-10-roster-hotfix", + ), + true, + ); + }); + + it("hides when the latest slug was already seen", () => { + assert.equal( + shouldShowPatchNotesPopup( + "2026-08-10-roster-hotfix", + "2026-08-10-roster-hotfix", + ), + false, + ); + }); + + it("hides when there is no latest note", () => { + assert.equal(shouldShowPatchNotesPopup(null, null), false); + assert.equal(shouldShowPatchNotesPopup("", "x"), false); + }); +}); diff --git a/src/lib/patch-notes-seen.ts b/src/lib/patch-notes-seen.ts new file mode 100644 index 0000000..76ef1cc --- /dev/null +++ b/src/lib/patch-notes-seen.ts @@ -0,0 +1,27 @@ +/** localStorage key for the latest patch-note slug the user has acknowledged. */ +export const PATCH_NOTES_SEEN_KEY = "patchNotesSeenSlug"; + +export function readSeenPatchNoteSlug(): string | null { + try { + return localStorage.getItem(PATCH_NOTES_SEEN_KEY); + } catch { + return null; + } +} + +export function writeSeenPatchNoteSlug(slug: string): void { + try { + localStorage.setItem(PATCH_NOTES_SEEN_KEY, slug); + } catch { + // privacy mode / blocked — popup may reappear; acceptable + } +} + +/** Show when there is a latest note and it differs from what was last dismissed. */ +export function shouldShowPatchNotesPopup( + latestSlug: string | null | undefined, + seenSlug: string | null | undefined, +): boolean { + if (!latestSlug) return false; + return latestSlug !== (seenSlug ?? null); +} diff --git a/src/lib/patch-notes.test.ts b/src/lib/patch-notes.test.ts new file mode 100644 index 0000000..f8fe9e6 --- /dev/null +++ b/src/lib/patch-notes.test.ts @@ -0,0 +1,87 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + isPatchNoteFilename, + parsePatchNoteMarkdown, + renderPatchNoteBody, + slugFromFilename, +} from "./patch-notes.ts"; + +const SAMPLE = `--- +title: Roster sync hotfix & patch notes +date: 2026-08-10 +summary: Logged-in roster saves were rejecting valid uploads. +--- + +## Roster sync hotfix + +Saves now send only \`name_id\` and \`isOwned\`. + +## Patch notes + +- the **Patch notes** page +- *Discord* announcements + +See [the site](https://lightkeepers.moe/patch-notes). +`; + +describe("patch notes", () => { + it("slugFromFilename strips path and extension", () => { + assert.equal( + slugFromFilename("patch-notes/2026-08-10-roster-hotfix.md"), + "2026-08-10-roster-hotfix", + ); + }); + + it("isPatchNoteFilename requires dated note files", () => { + assert.equal(isPatchNoteFilename("2026-08-10-roster-hotfix.md"), true); + assert.equal(isPatchNoteFilename("README.md"), false); + }); + + it("parsePatchNoteMarkdown reads frontmatter and body", () => { + const note = parsePatchNoteMarkdown( + "2026-08-10-roster-hotfix-and-patch-notes.md", + SAMPLE, + ); + assert.equal(note.slug, "2026-08-10-roster-hotfix-and-patch-notes"); + assert.equal(note.title, "Roster sync hotfix & patch notes"); + assert.equal(note.date, "2026-08-10"); + assert.match(note.body, /Roster sync hotfix/); + }); + + it("parsePatchNoteMarkdown accepts valid calendar dates", () => { + const leap = parsePatchNoteMarkdown( + "2024-02-29-leap.md", + SAMPLE.replace("date: 2026-08-10", "date: 2024-02-29"), + ); + assert.equal(leap.date, "2024-02-29"); + }); + + it("parsePatchNoteMarkdown rejects calendar-invalid dates", () => { + for (const bad of ["2026-02-31", "2026-99-99", "2026-00-10", "2026-13-01"]) { + assert.throws( + () => + parsePatchNoteMarkdown( + "bad.md", + SAMPLE.replace("date: 2026-08-10", `date: ${bad}`), + ), + /valid YYYY-MM-DD/, + ); + } + }); + + it("renderPatchNoteBody emits trusted HTML", () => { + const html = renderPatchNoteBody( + parsePatchNoteMarkdown("x.md", SAMPLE).body, + ); + assert.match(html, /

Roster sync hotfix<\/h2>/); + assert.match(html, /name_id<\/code>/); + assert.match(html, /Patch notes<\/strong>/); + assert.match(html, /Discord<\/em>/); + assert.match( + html, + //); + }); +}); diff --git a/src/lib/patch-notes.ts b/src/lib/patch-notes.ts new file mode 100644 index 0000000..251def6 --- /dev/null +++ b/src/lib/patch-notes.ts @@ -0,0 +1,163 @@ +/** + * Patch notes parsing / rendering — pure helpers (no Vite glob). + * Markdown under repo-root `patch-notes/` is the source of truth. + */ + +export type PatchNoteMeta = { + slug: string; + title: string; + date: string; + summary: string; +}; + +export type PatchNote = PatchNoteMeta & { + body: string; +}; + +const NOTE_FILENAME_RE = /^\d{4}-\d{2}-\d{2}-.+\.md$/i; + +function parseFrontmatter(raw: string): { + title: string; + date: string; + summary: string; + body: string; +} { + const text = raw.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n"); + const match = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/.exec(text); + if (!match) { + throw new Error("patch note missing YAML frontmatter"); + } + const yaml = match[1]!; + const body = match[2]!.trim(); + const fields: Record = {}; + for (const line of yaml.split("\n")) { + const i = line.indexOf(":"); + if (i <= 0) continue; + const key = line.slice(0, i).trim(); + let value = line.slice(i + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + fields[key] = value; + } + const title = fields.title?.trim(); + const date = fields.date?.trim(); + const summary = fields.summary?.trim(); + if (!title || !date || !summary) { + throw new Error("patch note frontmatter requires title, date, summary"); + } + if (!isValidCalendarDate(date)) { + throw new Error(`patch note date must be a valid YYYY-MM-DD (got ${date})`); + } + return { title, date, summary, body }; +} + +/** YYYY-MM-DD that actually exists on the calendar (rejects 2026-02-31, etc.). */ +function isValidCalendarDate(date: string): boolean { + if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return false; + const [y, m, d] = date.split("-").map(Number); + if (y == null || m == null || d == null) return false; + const dt = new Date(Date.UTC(y, m - 1, d)); + return ( + dt.getUTCFullYear() === y && + dt.getUTCMonth() === m - 1 && + dt.getUTCDate() === d + ); +} + +/** Filename `2026-08-10-roster-hotfix.md` → slug `2026-08-10-roster-hotfix`. */ +export function slugFromFilename(filename: string): string { + const base = filename.replace(/\\/g, "/").split("/").pop() ?? filename; + return base.replace(/\.md$/i, ""); +} + +export function isPatchNoteFilename(filename: string): boolean { + const base = filename.replace(/\\/g, "/").split("/").pop() ?? filename; + return NOTE_FILENAME_RE.test(base); +} + +export function parsePatchNoteMarkdown( + filename: string, + raw: string, +): PatchNote { + const { title, date, summary, body } = parseFrontmatter(raw); + return { + slug: slugFromFilename(filename), + title, + date, + summary, + body, + }; +} + +/** + * Minimal markdown → HTML for trusted author content (patch notes only). + * Supports paragraphs, ATX h2/h3, unordered lists, links, bold, italic, code. + */ +export function renderPatchNoteBody(md: string): string { + const lines = md.replace(/\r\n/g, "\n").split("\n"); + const out: string[] = []; + let i = 0; + + const inline = (text: string): string => { + let s = escapeHtml(text); + s = s.replace( + /\[([^\]]+)\]\((https?:[^)\s]+)\)/g, + '$1', + ); + s = s.replace(/\*\*([^*]+)\*\*/g, "$1"); + s = s.replace(/\*([^*]+)\*/g, "$1"); + s = s.replace(/`([^`]+)`/g, "$1"); + return s; + }; + + while (i < lines.length) { + const line = lines[i]!; + if (!line.trim()) { + i += 1; + continue; + } + const heading = /^(#{2,3})\s+(.+)$/.exec(line); + if (heading) { + const level = heading[1]!.length; + out.push(`${inline(heading[2]!.trim())}`); + i += 1; + continue; + } + if (/^[-*]\s+/.test(line)) { + const items: string[] = []; + while (i < lines.length && /^[-*]\s+/.test(lines[i]!)) { + items.push( + `
  • ${inline(lines[i]!.replace(/^[-*]\s+/, "").trim())}
  • `, + ); + i += 1; + } + out.push(`
      ${items.join("")}
    `); + continue; + } + const paras: string[] = [line]; + i += 1; + while ( + i < lines.length && + lines[i]!.trim() && + !/^#{2,3}\s/.test(lines[i]!) && + !/^[-*]\s+/.test(lines[i]!) + ) { + paras.push(lines[i]!); + i += 1; + } + out.push(`

    ${inline(paras.join(" ").trim())}

    `); + } + return out.join("\n"); +} + +function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} diff --git a/src/lib/site.ts b/src/lib/site.ts index 145dfee..7181397 100644 --- a/src/lib/site.ts +++ b/src/lib/site.ts @@ -1,2 +1,3 @@ /** Public site links shared across layout / home / nav. */ +export const SITE_ORIGIN = "https://lightkeepers.moe"; export const DISCORD_INVITE_URL = "https://discord.gg/D7RbZgCFCC"; diff --git a/src/lib/types/investment.ts b/src/lib/types/investment.ts index 0aeed66..8f6938b 100644 --- a/src/lib/types/investment.ts +++ b/src/lib/types/investment.ts @@ -1,253 +1,345 @@ -// ── gcsim investment comparison types ──────────────────────────────────────── -// Shape of investment.json.gz served from R2 (sim/investment.json.gz) - -export interface InvestmentFile { - teams: InvestmentTeam[]; - /** Sorted unique sim costs across all teams (merge-time). */ - available_costs?: number[]; -} - -export interface InvestmentTeam { - version: number; - team_key: string; - team_name: string; - /** Total cost to acquire the baseline team (limited5 = 1 copy each). */ - baseline_cost: number; - /** Character keys in the team, sorted alphabetically. */ - characters: string[]; - /** - * Characters for whom this team's floor-tier DPS (`results[0]`) is their - * global best (merge-time). Ties include all winners. - */ - is_best_for?: string[]; - /** - * Per exact cost: characters for whom this team's DPS at that cost is best. - * Keys are cost strings (e.g. `"2"`). - */ - is_best_for_at_cost?: Record; - /** All simulated investment levels for this team, sorted by cost → dps descending. */ - results: InvestmentSim[]; -} - -export type SimKind = "baseline" | "f2p" | "vertical" | "talent"; - -export interface InvestmentSim { - /** Stable key: characters sorted, Char~C{cons}~{weapon}, joined by __ */ - state_key: string; - /** - * Human-readable label. - * - baseline: full config (every char C / weapon / R) - * - f2p / vertical: diffs from baseline only (e.g. "Flins C1", "Aino C2") - */ - label: string; - /** - * baseline = canonical starting build; - * f2p = floor-cost alternative (free weapon / 4★ budget cons, etc.); - * vertical = limited-pull upgrades (extra cost above floor); - * talent = one-step talent drop from baseline (single char, one talent at 1). - */ - kind: SimKind; - /** Total cost in limited5 copies (baseline + upgrades). */ - cost: number; - /** Simulated DPS from gcsim. */ - dps: number; - /** Per-character build snapshot for this simulation. */ - characters: CharacterBuild[]; -} - -export interface CharacterBuild { - key: string; - cons: number; - weapon: { - key: string; - refinement: number; - level: number; - }; - set: { - key: string; - count: number; - }; - /** Second 2pc set when running 2pc/2pc (flat fields from pipeline summaries). */ - set2?: string; - set2_count?: number; - main_stats: { - sands: string; - goblet: string; - circlet: string; - }; - level: number; - talents: { - auto: number; - skill: number; - burst: number; - }; - /** - * Total substat rolls from gcsim OptimFull (`stat=*N` in config). - * Includes the fixed baseline (default 2 per substat). - * Keys are GOOD StatKey values (e.g. critDMG_, atk_). - */ - substat_rolls?: Record; - /** Liquid rolls only (total minus fixed baseline of 2). */ - substat_rolls_liquid?: Record; -} - -/** Shape of `output/characters.json` (aggregate character summaries). */ -export interface CharacterIndexFile { - characters: Record; -} - -export interface CharacterIndex { - key: string; - /** - * Weapons ranked by distinct team count. Baseline weapons always count; - * F2P weapon alts only count teams where that f2p config beats baseline DPS; - * vertical/sig weapons still count every appearance. - */ - weapons: CharacterWeaponRank[]; - /** - * Artifact sets from each team's baseline sim only - * (one vote per team). Ranked by team count; `count` is 2 or 4 pieces. - */ - sets: CharacterSetRank[]; - /** - * Per-slot main-stat frequency from each team's baseline sim only - * (one vote per team). Ranked by team count. - */ - main_stats: { - sands: CharacterStatRank[]; - goblet: CharacterStatRank[]; - circlet: CharacterStatRank[]; - }; - /** - * Mean OptimFull liquid rolls: average f2p + bp_limited-weapon configs - * within a team, then average those team-means across teams. - */ - substat_rolls_liquid: CharacterLiquidSubstats; - /** - * How much DPS drops when each talent is lowered to 1 (others stay baseline), - * aggregated across teams. Character level is tracked separately. - */ - talent_importance?: CharacterTalentImportance; - /** - * How much DPS drops when the character runs at level 80 (talents stay - * baseline), aggregated across teams. - */ - level_importance?: CharacterLevelImportance; - /** - * One-step gains: constellations are stepwise vs the previous constellation - * (C2 vs C1), covering rungs below the team baseline as well as above it; - * sig weapons are vs baseline. Combined cons+sig and multi-char results are - * excluded. - */ - vertical_importance?: CharacterVerticalImportance; - /** - * Editorial upgrade recommendations from a hand-authored guide. - * Published separately from measured `*_importance` statistics; the Builds - * UI fills missing sim sections by default, or replaces authored sections - * when `override` is true. - */ - guide_priority?: CharacterGuidePriority; - /** Optional editorial blurb from hand-authored guide (merge-time). */ - notes?: string; -} - -export type TalentSlot = "auto" | "skill" | "burst"; - -/** - * Hand-authored upgrade recommendations. No percentages or impact labels — - * order / selection is the editorial signal. - */ -export interface CharacterGuidePriority { - /** When true, authored sections replace sim sections (omitted sections never override). */ - override: boolean; - /** Talent slots in recommended priority order. */ - talent_priority?: TalentSlot[]; - /** Recommend raising the character to level 90. */ - level_90?: true; - /** Recommended constellation stopping points (ascending). */ - constellations?: number[]; - /** Signature weapon GOOD keys in recommendation order. */ - sig_weapons?: string[]; -} - -export interface CharacterTalentSlotImportance { - /** Average % DPS drop vs baseline across teams. */ - mean_pct_drop: number; - /** Median % DPS drop vs baseline across teams. */ - median_pct_drop: number; - /** Smallest % DPS drop on any contributing team. */ - min_pct_drop: number; - /** Largest % DPS drop on any contributing team. */ - max_pct_drop: number; -} - -export interface CharacterTalentImportance { - /** Teams with baseline + all three talent drops for this character. */ - teams: number; - auto: CharacterTalentSlotImportance; - skill: CharacterTalentSlotImportance; - burst: CharacterTalentSlotImportance; - /** Talent slots ranked by mean % drop (highest first). */ - priority: TalentSlot[]; -} - -export interface CharacterLevelImportance extends CharacterTalentSlotImportance { - /** Teams with a level-80 drop sample for this character. */ - teams: number; -} - -export interface CharacterVerticalGain { - /** Teams that contributed a one-step vertical sample for this entry. */ - teams: number; - /** Average % DPS gain vs that team's baseline. */ - mean_pct_gain: number; - /** Median % DPS gain vs that team's baseline. */ - median_pct_gain: number; - /** Smallest % DPS gain on any contributing team. */ - min_pct_gain: number; - /** Largest % DPS gain on any contributing team. */ - max_pct_gain: number; -} - -export interface CharacterConsGain extends CharacterVerticalGain { - /** Absolute constellation level reached (e.g. 1 for C1). Gain is vs C{cons-1}. */ - cons: number; -} - -export interface CharacterSigGain extends CharacterVerticalGain { - /** Signature weapon GOOD key. */ - key: string; -} - -export interface CharacterVerticalImportance { - /** Cons-only upgrades, sorted by cons ascending. May start at C1. */ - constellations: CharacterConsGain[]; - /** Sig-weapon-only upgrades, sorted by mean gain descending. */ - sig_weapons: CharacterSigGain[]; -} - -export interface CharacterWeaponRank { - key: string; - teams: number; -} - -export interface CharacterSetRank { - key: string; - /** Piece count bucket: 2 (from 2–3) or 4 (from 4–5). */ - count: number; - teams: number; -} - -export interface CharacterStatRank { - key: string; - teams: number; -} - -export interface CharacterLiquidSubstats { - /** Teams that contributed at least one liquid sample. */ - teams: number; - /** Total configs averaged before the per-team collapse. */ - configs: number; - mean: Record; - ranked: Array<{ key: string; mean: number }>; -} +// ── gcsim investment comparison types ──────────────────────────────────────── +// Shape of investment.json.gz served from R2 (sim/investment.json.gz) + +export interface InvestmentFile { + teams: InvestmentTeam[]; + /** Sorted unique sim costs across all teams (merge-time). */ + available_costs?: number[]; +} + +export interface InvestmentTeam { + version: number; + team_key: string; + team_name: string; + /** Total cost to acquire the baseline team (limited5 = 1 copy each). */ + baseline_cost: number; + /** Character keys in the team, sorted alphabetically. */ + characters: string[]; + /** + * Characters for whom this team's floor-tier DPS (`results[0]`) is their + * global best (merge-time). Ties include all winners. + */ + is_best_for?: string[]; + /** + * Per exact cost: characters for whom this team's DPS at that cost is best. + * Keys are cost strings (e.g. `"2"`). + */ + is_best_for_at_cost?: Record; + /** All simulated investment levels for this team, sorted by cost → dps descending. */ + results: InvestmentSim[]; +} + +export type SimKind = "baseline" | "f2p" | "owned" | "vertical" | "talent"; + +export interface InvestmentSim { + /** Stable key: characters sorted, Char~C{cons}~{weapon}, joined by __ */ + state_key: string; + /** + * Human-readable label. + * - baseline: full config (every char C / weapon / R) + * - f2p / owned / vertical: diffs from baseline only (e.g. "Flins C1", "Aino C2") + */ + label: string; + /** + * baseline = canonical starting build; + * f2p = floor-cost alternative (free weapon / 4★ budget cons, etc.); + * owned = already-owned 5★ weapon alt (+1 cost, vs baseline — not a pull rung); + * vertical = limited-pull upgrades (extra cost above floor); + * talent = one-step talent drop from baseline (single char, one talent at 1). + */ + kind: SimKind; + /** Total cost in limited5 copies (baseline + upgrades). */ + cost: number; + /** Simulated DPS from gcsim. */ + dps: number; + /** Per-character build snapshot for this simulation. */ + characters: CharacterBuild[]; +} + +export interface CharacterBuild { + key: string; + cons: number; + weapon: { + key: string; + refinement: number; + level: number; + }; + set: { + key: string; + count: number; + }; + /** Second 2pc set when running 2pc/2pc (flat fields from pipeline summaries). */ + set2?: string; + set2_count?: number; + main_stats: { + sands: string; + goblet: string; + circlet: string; + }; + level: number; + talents: { + auto: number; + skill: number; + burst: number; + }; + /** + * Total substat rolls from gcsim OptimFull (`stat=*N` in config). + * Includes the fixed baseline (default 2 per substat). + * Keys are GOOD StatKey values (e.g. critDMG_, atk_). + */ + substat_rolls?: Record; + /** Liquid rolls only (total minus fixed baseline of 2). */ + substat_rolls_liquid?: Record; +} + +/** Windowed dropoff impact bands (same vocabulary as Builds upgrade tiers). */ +export type ImportanceImpactTier = + | "exceptional" + | "high" + | "solid" + | "modest" + | "negligible"; + +/** Shape of `output/characters.json` (aggregate character summaries). */ +export interface CharacterIndexFile { + characters: Record; + /** Roster-level impact bucket floors + labels (also copied onto each character). */ + impact_tiers?: ImpactTiersMeta; +} + +/** One impact scale: inclusive % floors and display labels (k may be 3–5). */ +export interface ImpactTierScale { + bucket_count?: number | null; + floors: Partial>; + labels: Partial>; +} + +/** + * Merge-time impact buckets uploaded with character summaries. + * Each upgrade axis has its own floors (talents vs cons vs sigs vs artifacts). + */ +export interface ImpactTiersMeta { + talents: ImpactTierScale; + constellations: ImpactTierScale; + sig_weapons: ImpactTierScale; + artifacts: ImpactTierScale; +} + +export interface CharacterIndex { + key: string; + /** + * Roster impact bucket definitions (embedded on per-character CDN JSON so + * Builds can label stamped tiers without fetching the full index). + */ + impact_tiers?: ImpactTiersMeta; + /** + * Weapons ranked by Bradley–Terry strength (same-team weapon one-steps), + * then team count. Baseline weapons always count; F2P / owned alts only + * count teams where that config beats baseline DPS; vertical/sig weapons + * still count every appearance. + */ + weapons: CharacterWeaponRank[]; + /** + * Artifact sets from each team's baseline sim only + * (one vote per team). Ranked by team count; `count` is 2 or 4 pieces. + */ + sets: CharacterSetRank[]; + /** + * Per-slot main-stat frequency from each team's baseline sim only + * (one vote per team). Ranked by team count. + */ + main_stats: { + sands: CharacterStatRank[]; + goblet: CharacterStatRank[]; + circlet: CharacterStatRank[]; + }; + /** + * Mean OptimFull liquid rolls: average f2p + bp_limited-weapon configs + * within a team, then average those team-means across teams. + */ + substat_rolls_liquid: CharacterLiquidSubstats; + /** + * How much DPS drops when each talent is lowered to 1 (others stay baseline), + * aggregated across teams. Character level is tracked separately. + */ + talent_importance?: CharacterTalentImportance; + /** + * How much DPS drops when the character runs at level 80 (talents stay + * baseline), aggregated across teams. + */ + level_importance?: CharacterLevelImportance; + /** + * One-step gains: constellations are stepwise vs the previous constellation + * (C2 vs C1), covering rungs below the team baseline as well as above it; + * sig weapons are vs baseline. Combined cons+sig and multi-char results are + * excluded. + */ + vertical_importance?: CharacterVerticalImportance; + /** + * One-hot high-invest artifact gain vs mid baseline (30/18/1 on this character, + * others at pipeline mid OptimFull). Aggregated across teams at merge from + * `artifact_importance.py` reports. Negative per-team gains are floored to 0. + */ + artifact_importance?: CharacterArtifactImportance; + /** + * Editorial upgrade recommendations from a hand-authored guide. + * Published separately from measured `*_importance` statistics; the Builds + * UI fills missing sim sections by default, or replaces authored sections + * when `override` is true. + */ + guide_priority?: CharacterGuidePriority; + /** Optional editorial blurb from hand-authored guide (merge-time). */ + notes?: string; +} + +/** Compact team reaction profile from baseline gcsim ``-out`` extract. */ +export interface TeamReactionProfile { + rps: number | null; + /** Which signal ranked the list. */ + metric: "damage" | "count"; + list: TeamReactionEntry[]; + primary: string | null; + /** Diversity key, e.g. ``melt`` or ``melt+vaporize``. */ + fingerprint: string | null; +} + +export interface TeamReactionEntry { + name: string; + damage: number; + count: number; + share: number; +} + + +export type TalentSlot = "auto" | "skill" | "burst"; + +/** + * Hand-authored upgrade recommendations. No percentages or impact labels — + * order / selection is the editorial signal. + */ +export interface CharacterGuidePriority { + /** When true, authored sections replace sim sections (omitted sections never override). */ + override: boolean; + /** Talent slots in recommended priority order. */ + talent_priority?: TalentSlot[]; + /** Recommend raising the character to level 90. */ + level_90?: true; + /** Recommended constellation stopping points (ascending). */ + constellations?: number[]; + /** Signature weapon GOOD keys in recommendation order. */ + sig_weapons?: string[]; +} + +export interface CharacterTalentSlotImportance { + /** Average % DPS drop vs baseline across teams. */ + mean_pct_drop: number; + /** Median % DPS drop vs baseline across teams. */ + median_pct_drop: number; + /** Smallest % DPS drop on any contributing team. */ + min_pct_drop: number; + /** Largest % DPS drop on any contributing team. */ + max_pct_drop: number; + /** + * Merge-time impact bucket from the joint talent+cons pool cutoffs. + * Null when this slot was not scored. + */ + tier?: ImportanceImpactTier | null; +} + +export interface CharacterTalentImportance { + /** Teams with baseline + all three talent drops for this character. */ + teams: number; + auto: CharacterTalentSlotImportance; + skill: CharacterTalentSlotImportance; + burst: CharacterTalentSlotImportance; + /** Talent slots ranked by mean % drop (highest first). */ + priority: TalentSlot[]; +} + +export interface CharacterLevelImportance extends CharacterTalentSlotImportance { + /** Teams with a level-80 drop sample for this character. */ + teams: number; +} + +export interface CharacterVerticalGain { + /** Teams that contributed a one-step vertical sample for this entry. */ + teams: number; + /** Average % DPS gain vs that team's baseline. */ + mean_pct_gain: number; + /** Median % DPS gain vs that team's baseline. */ + median_pct_gain: number; + /** Smallest % DPS gain on any contributing team. */ + min_pct_gain: number; + /** Largest % DPS gain on any contributing team. */ + max_pct_gain: number; + /** + * Merge-time impact bucket from shared talent+cons cutoff floors. + * Null when this entry was not scored. + */ + tier?: ImportanceImpactTier | null; +} + +export interface CharacterConsGain extends CharacterVerticalGain { + /** Absolute constellation level reached (e.g. 1 for C1). Gain is vs C{cons-1}. */ + cons: number; +} + +export interface CharacterSigGain extends CharacterVerticalGain { + /** Signature weapon GOOD key. */ + key: string; +} + +export interface CharacterVerticalImportance { + /** Cons-only upgrades, sorted by cons ascending. May start at C1. */ + constellations: CharacterConsGain[]; + /** Sig-weapon-only upgrades, sorted by mean gain descending. */ + sig_weapons: CharacterSigGain[]; +} + +/** Gain from juicing this character's artifacts (high OptimFull) while teammates stay mid. */ +export interface CharacterArtifactImportance { + teams: number; + mean_pct_gain: number; + median_pct_gain: number; + min_pct_gain: number; + max_pct_gain: number; + /** + * Merge-time impact bucket from successive largest-dropoff cuts on mean_pct_gain. + * Null when no samples. + */ + tier?: ImportanceImpactTier | null; +} + +/** @deprecated Use {@link ImportanceImpactTier}. */ +export type ArtifactImportanceTier = ImportanceImpactTier; + +export interface CharacterWeaponRank { + key: string; + teams: number; + /** + * Bradley–Terry strength from same-team pairwise DPS among baseline + + * one-weapon-step configs. Higher = stronger. Omitted on older payloads. + */ + strength?: number; +} + +export interface CharacterSetRank { + key: string; + /** Piece count bucket: 2 (from 2–3) or 4 (from 4–5). */ + count: number; + teams: number; +} + +export interface CharacterStatRank { + key: string; + teams: number; +} + +export interface CharacterLiquidSubstats { + /** Teams that contributed at least one liquid sample. */ + teams: number; + /** Total configs averaged before the per-team collapse. */ + configs: number; + mean: Record; + ranked: Array<{ key: string; mean: number }>; +} diff --git a/src/lib/ui/NavBar.svelte b/src/lib/ui/NavBar.svelte index 9c78dc6..b5850d0 100644 --- a/src/lib/ui/NavBar.svelte +++ b/src/lib/ui/NavBar.svelte @@ -9,7 +9,6 @@ import IconUser from "$lib/ui/icons/IconUser.svelte"; import IconCloudUp from "$lib/ui/icons/IconCloudUp.svelte"; import IconMonitor from "$lib/ui/icons/IconMonitor.svelte"; - import IconDiscord from "$lib/ui/icons/IconDiscord.svelte"; import { DISCORD_INVITE_URL } from "$lib/site"; import { backgroundVisible, toggleBackgroundVisible } from "$lib/stores"; @@ -20,6 +19,7 @@ const teamsPath = resolve("/teams"); const charactersPath = resolve("/characters"); const settingsPath = resolve("/settings"); + const patchNotesPath = resolve("/patch-notes"); const settingsLinks = [ { label: "Roster", path: resolve("/settings"), icon: "users" as const }, { @@ -381,26 +381,28 @@ {/if} @@ -801,70 +803,17 @@ display: flex; flex-direction: column; align-items: stretch; - gap: var(--space-3); - padding: 0.85rem 0.75rem 0.25rem; + gap: 0.15rem; + padding: 0.85rem 0.75rem 0.85rem; border-top: var(--border-width) solid color-mix(in srgb, var(--accent-3) 16%, transparent); } - .drawer-discord { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.7rem 0.85rem; - border-radius: var(--radius-md); - border: var(--border-width) solid - color-mix(in srgb, var(--accent-3) 22%, transparent); - background: color-mix(in srgb, var(--foreground-color) 5%, transparent); - font-family: var(--font-display); - font-size: var(--text-md); - font-weight: 500; - color: var(--foreground-color); - text-decoration: none; - transition: - background-color var(--control-duration) var(--control-ease), - border-color var(--control-duration) var(--control-ease); - } - - .drawer-discord:hover { - background: color-mix(in srgb, var(--foreground-color) 9%, transparent); - border-color: color-mix(in srgb, var(--accent-3) 40%, transparent); - } - - .drawer-discord-copy { - display: flex; - flex-direction: column; - gap: 0.1rem; - min-width: 0; - } - - .drawer-discord-hint { - font-size: 0.7rem; - font-weight: 500; - letter-spacing: 0.04em; - color: color-mix(in srgb, var(--foreground-mid) 75%, transparent); - } - - .drawer-mascot-wrap { - display: flex; - justify-content: center; - padding: 0.15rem 0 0.35rem; - mask-image: linear-gradient( - 180deg, - transparent 0%, - #000 28%, - #000 72%, - transparent 100% - ); - } - - .drawer-mascot { - width: 58%; - max-width: 8rem; - height: auto; - opacity: 0.55; - pointer-events: none; - filter: saturate(0.9); + .drawer-guoba { + width: 22px; + height: 22px; + object-fit: contain; + display: block; } @keyframes drawer-item-in { diff --git a/src/lib/ui/components/CostPopover.svelte b/src/lib/ui/components/CostPopover.svelte index f8582ca..51a17f6 100644 --- a/src/lib/ui/components/CostPopover.svelte +++ b/src/lib/ui/components/CostPopover.svelte @@ -15,4 +15,7 @@ Every limited copy of a five star character or weapon adds 1 cost to the team. Standard characters are assigned 0-cost at C2, and characters in the constellation selector get their C1 for free, so they are 1-cost at C1. + Owned weapon alternatives on team pages (other signatures, standard 5★ + weapons you already have) are also counted as +1 for comparison — they are + not pull recommendations. diff --git a/src/lib/ui/components/HoverTooltip.svelte b/src/lib/ui/components/HoverTooltip.svelte index 8627c4a..a117cde 100644 --- a/src/lib/ui/components/HoverTooltip.svelte +++ b/src/lib/ui/components/HoverTooltip.svelte @@ -16,7 +16,6 @@ const EDGE = 8; const GAP = 8; - const LEAVE_DELAY_MS = 120; const tooltipId = $props.id(); let tipEl: HTMLDivElement | undefined = $state(); @@ -26,25 +25,7 @@ let activeTriggerEl: HTMLElement | null = null; let open = $state(false); let detailOpen = $state(false); - let tipScrollable = $state(false); - - let triggerHovered = false; - let tipHovered = false; - let leaveTimer: ReturnType | null = null; - - function clearLeaveTimer() { - if (leaveTimer == null) return; - clearTimeout(leaveTimer); - leaveTimer = null; - } - - function scheduleHide() { - clearLeaveTimer(); - leaveTimer = setTimeout(() => { - leaveTimer = null; - if (!triggerHovered && !tipHovered) hideTip(); - }, LEAVE_DELAY_MS); - } + let truncated = $state(false); function updateTriggerDescription(trigger: HTMLElement | null, add: boolean) { if (!trigger) return; @@ -123,22 +104,10 @@ const tip = tipEl; if (!tip) return; - tip.style.maxHeight = ""; - tip.style.overflow = ""; - tipScrollable = false; - const t = trigger.getBoundingClientRect(); + const r = tip.getBoundingClientRect(); const vw = window.innerWidth; const vh = window.innerHeight; - const maxH = Math.max(0, vh - EDGE * 2); - - let r = tip.getBoundingClientRect(); - if (r.height > maxH) { - tip.style.maxHeight = `${maxH}px`; - tip.style.overflow = "auto"; - tipScrollable = true; - r = tip.getBoundingClientRect(); - } const aboveTop = t.top - r.height - GAP; const belowTop = t.bottom + GAP; @@ -158,6 +127,12 @@ tip.style.left = `${left}px`; } + function measureTruncation() { + const tip = tipEl; + if (!tip) return; + truncated = tip.scrollHeight > tip.clientHeight + 2; + } + function showTip(trigger: HTMLElement) { if (detailOpen) return; tipTriggerEl = trigger; @@ -165,15 +140,15 @@ updateTriggerDescription(trigger, true); requestAnimationFrame(() => { place(trigger); + measureTruncation(); + // Truncation adds a footer; re-place once layout settles. + if (truncated) requestAnimationFrame(() => place(trigger)); }); } function hideTip() { - clearLeaveTimer(); - triggerHovered = false; - tipHovered = false; - tipScrollable = false; open = false; + truncated = false; if (!detailOpen) updateTriggerDescription(tipTriggerEl, false); tipTriggerEl = null; } @@ -195,17 +170,14 @@ const trigger = event.currentTarget as HTMLElement; if (isInteractiveDescendant(event.target, trigger)) return; - // Every tip opens its sheet on tap / click so touch users get the same - // affordance everywhere. + // Every tip opens its sheet on tap / click, truncated or not, so touch + // users get the same affordance everywhere. event.preventDefault(); event.stopPropagation(); - clearLeaveTimer(); tipTriggerEl = null; activeTriggerEl = trigger; - triggerHovered = false; - tipHovered = false; open = false; - tipScrollable = false; + truncated = false; updateTriggerDescription(trigger, false); detailOpen = true; await tick(); @@ -231,28 +203,13 @@ const trigger = tip.parentElement; if (!trigger) return; - // Portal to body so `position: fixed` is viewport-relative. Transformed - // ancestors (e.g. TeamCardHand fan cards) otherwise become the containing - // block and viewport coords from getBoundingClientRect land in the wrong place. + // Portal to body so `position: fixed` is viewport-relative. Transformed / + // overflow:hidden ancestors otherwise clip the tip or become the containing + // block so viewport coords from getBoundingClientRect land wrong. document.body.appendChild(tip); - const onTriggerEnter = () => { - triggerHovered = true; - clearLeaveTimer(); - showTip(trigger); - }; - const onTriggerLeave = () => { - triggerHovered = false; - scheduleHide(); - }; - const onTipEnter = () => { - tipHovered = true; - clearLeaveTimer(); - }; - const onTipLeave = () => { - tipHovered = false; - scheduleHide(); - }; + const onEnter = () => showTip(trigger); + const onLeave = () => hideTip(); const onClick = (event: Event) => { void openDetail(event); }; @@ -282,12 +239,10 @@ } }; - trigger.addEventListener("pointerenter", onTriggerEnter); - trigger.addEventListener("pointerleave", onTriggerLeave); - tip.addEventListener("pointerenter", onTipEnter); - tip.addEventListener("pointerleave", onTipLeave); - trigger.addEventListener("focusin", onTriggerEnter); - trigger.addEventListener("focusout", onTriggerLeave); + trigger.addEventListener("pointerenter", onEnter); + trigger.addEventListener("pointerleave", onLeave); + trigger.addEventListener("focusin", onEnter); + trigger.addEventListener("focusout", onLeave); trigger.addEventListener("click", onClick); window.addEventListener("scroll", reposition, true); window.addEventListener("resize", reposition); @@ -295,16 +250,13 @@ window.addEventListener("keydown", onKey); return () => { - clearLeaveTimer(); updateTriggerDescription(tipTriggerEl, false); updateTriggerDescription(activeTriggerEl, false); tipTriggerEl = null; - trigger.removeEventListener("pointerenter", onTriggerEnter); - trigger.removeEventListener("pointerleave", onTriggerLeave); - tip.removeEventListener("pointerenter", onTipEnter); - tip.removeEventListener("pointerleave", onTipLeave); - trigger.removeEventListener("focusin", onTriggerEnter); - trigger.removeEventListener("focusout", onTriggerLeave); + trigger.removeEventListener("pointerenter", onEnter); + trigger.removeEventListener("pointerleave", onLeave); + trigger.removeEventListener("focusin", onEnter); + trigger.removeEventListener("focusout", onLeave); trigger.removeEventListener("click", onClick); window.removeEventListener("scroll", reposition, true); window.removeEventListener("resize", reposition); @@ -327,24 +279,26 @@ @@ -390,7 +344,8 @@ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35); opacity: 0; visibility: hidden; - pointer-events: none; + max-height: min(11rem, 40dvh); + overflow: hidden; transition: opacity 0.15s ease, visibility 0.15s ease; @@ -401,14 +356,37 @@ visibility: visible; } - .hover-tooltip-interactive { - pointer-events: auto; - } - .hover-tooltip-body { display: block; } + .hover-tooltip-truncated { + padding-bottom: 1.35rem; + } + + .hover-tooltip-fade { + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 2.75rem; + pointer-events: none; + background: linear-gradient(to top, var(--foreground-mid) 35%, transparent); + } + + .hover-tooltip-more { + position: absolute; + left: 0.65rem; + right: 0.65rem; + bottom: 0.35rem; + z-index: 1; + font-size: 9px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: color-mix(in srgb, var(--background-color) 72%, transparent); + } + /* Callers may still pass rem utilities; keep hover tip from scaling up. */ .hover-tooltip :global(.text-sm), .hover-tooltip :global(.text-xs), diff --git a/src/lib/ui/components/InvestmentBuildCard.svelte b/src/lib/ui/components/InvestmentBuildCard.svelte new file mode 100644 index 0000000..1300770 --- /dev/null +++ b/src/lib/ui/components/InvestmentBuildCard.svelte @@ -0,0 +1,617 @@ + + + +
    +
    + {#if character} +
    + +
    + + {#if kit?.constellations?.length} +
      + {#each kit.constellations as c (c.index)} + {@const unlocked = c.index <= build.cons} +
    • + {#if c.icon} + + {/if} + {#if !unlocked} + + {/if} +
    • + {/each} +
    + {/if} +
    + {/if} + +
    +

    {character?.name ?? build.key}

    +

    Lv. {build.level}

    +
    +
    + +
    +
    +
    + {#if wIcon} + + {/if} +
    +
    +

    + {weapon?.name ?? build.weapon.key} +

    + R{build.weapon.refinement} +
    + {#if weapon} + {@const stars = starCount(weapon.stars)} +
    + {#each Array.from({ length: stars }, (_, i) => i) as i (i)} + + {/each} +
    +
    + ATK {Math.round(weapon.baseAtk)} + {#if weapon.subStat} + + {weapon.subStat.label} + {weapon.subStat.isPercent + ? `${(weapon.subStat.value * 100).toFixed(1)}%` + : Math.round(weapon.subStat.value)} + + {/if} +
    + {/if} +
    + +
    +
    + + {#if sheet} +
    + {#each coreStats as row (row.key)} + + {/each} +
    + {:else} +

    Base stats unavailable for {build.key}

    + {/if} + +
    + {#each talentRows as row (row.slot)} + + {#if row.icon} + + {:else} + {row.fallback} + {/if} + {row.level} + + {/each} +
    + +
    +
    + {#if sIcon} + + {/if} +

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

    + {build.set.count} + +
    + {#if build.set2} +
    + {#if s2Icon} + + {/if} +

    {set2?.name ?? build.set2}

    + {build.set2_count ?? 2} + +
    + {/if} +
    +
    +
    +
    + + diff --git a/src/lib/ui/components/PatchNotesPopup.svelte b/src/lib/ui/components/PatchNotesPopup.svelte new file mode 100644 index 0000000..464f610 --- /dev/null +++ b/src/lib/ui/components/PatchNotesPopup.svelte @@ -0,0 +1,283 @@ + + +{#if open && note} + +{/if} + + diff --git a/src/lib/upgrade-priority.test.ts b/src/lib/upgrade-priority.test.ts index 105f549..0d1c654 100644 --- a/src/lib/upgrade-priority.test.ts +++ b/src/lib/upgrade-priority.test.ts @@ -7,6 +7,7 @@ import { TALENT_UPGRADE, classifyUpgradeImpact, primaryUpgradePct, + resolveUpgradeImpact, type UpgradeImpactLadder, } from "./upgrade-priority.ts"; @@ -63,6 +64,31 @@ describe("upgrade priority", () => { ); }); + it("prefers stamped CDN tiers over the ladder", () => { + assert.deepEqual( + resolveUpgradeImpact("solid", 30, ladder, { + floors: { + exceptional: 20, + high: 10, + solid: 5, + negligible: 0, + }, + labels: { + exceptional: "Exceptional impact", + high: "High impact", + solid: "Solid impact", + modest: "Modest impact", + negligible: "Negligible impact", + }, + }), + { tier: "solid", label: "Solid impact" }, + ); + assert.deepEqual(resolveUpgradeImpact(null, 31, ladder), { + tier: "exceptional", + label: "Exceptional", + }); + }); + it("ships ladders that descend and cover zero", () => { const shipped = { TALENT_UPGRADE, diff --git a/src/lib/upgrade-priority.ts b/src/lib/upgrade-priority.ts index bfa653b..d3b6833 100644 --- a/src/lib/upgrade-priority.ts +++ b/src/lib/upgrade-priority.ts @@ -5,12 +5,25 @@ * Call sites pass {@link primaryUpgradePct}(mean, median) so a skewed high * mean still surfaces when the median alone would understate the upgrade. * + * Measured talent / cons / sig / artifact rows prefer merge-stamped + * {@link ImportanceImpactTier} from CDN JSON; these ladders remain the + * fallback when `tier` is missing (stale payload, guide rows, level-90). + * * Each Builds section passes its own ordered threshold ladder, so both the * number of bands and their labels remain section-specific. */ +import type { + ImpactTierScale, + ImportanceImpactTier, +} from "$lib/types/investment"; + export type UpgradeTier = - "exceptional" | "high" | "solid" | "modest" | "negligible"; + | "exceptional" + | "high" + | "solid" + | "modest" + | "negligible"; export interface UpgradeImpactBand { /** Inclusive lower bound for this band. */ @@ -26,6 +39,15 @@ export interface UpgradeImpact { export type UpgradeImpactLadder = readonly UpgradeImpactBand[]; +/** Default labels for merge-stamped impact tiers (3–5 bands). */ +export const MERGED_IMPACT_LABELS: Record = { + exceptional: "Exceptional impact", + high: "High impact", + solid: "Solid impact", + modest: "Modest impact", + negligible: "Negligible impact", +}; + export const TALENT_UPGRADE: UpgradeImpactLadder = [ { minPct: 10, tier: "exceptional", label: "Essential to upgrade" }, { minPct: 7.5, tier: "high", label: "Highly recommended" }, @@ -91,3 +113,23 @@ export function classifyUpgradeImpact( } return { tier: band.tier, label: band.label }; } + +/** + * Prefer merge-stamped CDN tiers (+ roster labels/floors); fall back to the + * section ladder when the payload has no tier yet. + */ +export function resolveUpgradeImpact( + stamped: ImportanceImpactTier | null | undefined, + pct: number, + ladder: UpgradeImpactLadder, + scale?: ImpactTierScale | null, +): UpgradeImpact { + if (stamped) { + return { + tier: stamped, + label: + scale?.labels?.[stamped] ?? MERGED_IMPACT_LABELS[stamped] ?? stamped, + }; + } + return classifyUpgradeImpact(pct, ladder); +} diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index 7397654..1654cba 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -13,6 +13,7 @@ import { serverDb } from "$lib/server/supabaseServer"; import { charactersCache } from "$lib/server/cache"; import { isPlaywrightE2e } from "$lib/server/e2e"; import { e2eCharacters, e2eStaticPayload } from "$lib/e2e/fixtures"; +import { listPatchNotes } from "$lib/patch-notes-catalog"; import type { Tables } from "$lib/types/database.types"; type Character = Tables<"characters">; @@ -72,10 +73,22 @@ export const load: LayoutServerLoad = async () => { const mapping = new Map(); characters.forEach((c) => mapping.set(c.name_id, c)); + // Skip in Playwright so the popup never blocks e2e flows. + const latest = isPlaywrightE2e() ? undefined : listPatchNotes()[0]; + const latestPatchNote = latest + ? { + slug: latest.slug, + title: latest.title, + date: latest.date, + summary: latest.summary, + } + : null; + return { mapping, characters, abyssVersionNumber: versions.abyssVersionNumber, stygianVersionNumber: versions.stygianVersionNumber, + latestPatchNote, }; }; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 784ee51..b8b08be 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -21,9 +21,14 @@ syncBackgroundToPath, } from "$lib/stores"; import NavBar from "$lib/ui/NavBar.svelte"; + import PatchNotesPopup from "$lib/ui/components/PatchNotesPopup.svelte"; + import { resolve } from "$app/paths"; import { DISCORD_INVITE_URL } from "$lib/site"; import "../app.css"; + const patchNotesPath = resolve("/patch-notes"); + + if (typeof window !== "undefined") { installChunkLoadRecovery(); } @@ -94,6 +99,7 @@ > {/if} +
    @@ -114,6 +120,8 @@ > © Lightkeepers + Patch notes + { characters: data.characters as Character[], abyssVersionNumber: data.abyssVersionNumber as number, stygianVersionNumber: data.stygianVersionNumber as number, + latestPatchNote: data.latestPatchNote, }; }; diff --git a/src/routes/abyss/+page.svelte b/src/routes/abyss/+page.svelte index 0fad4a6..5a31366 100644 --- a/src/routes/abyss/+page.svelte +++ b/src/routes/abyss/+page.svelte @@ -6,6 +6,7 @@ staticBoardsLoaded, staticBoardsError, charactersOwned, + charactersHydrated, teamsOwnedLoaded, ensureTeamsOwned, ensureStaticBoards, @@ -90,7 +91,10 @@ let solution = $derived(displaySolutions[safeIndex]); let loading = $derived( - !$staticBoardsError && !$staticBoardsLoaded && $allTeamsAbyss.length === 0, + !$charactersHydrated || + (!$staticBoardsError && + !$staticBoardsLoaded && + $allTeamsAbyss.length === 0), ); let waitingForOwned = $derived(hasOwnedCharacters && !$teamsOwnedLoaded); diff --git a/src/routes/characters/+page.svelte b/src/routes/characters/+page.svelte index 9744746..3bd597c 100644 --- a/src/routes/characters/+page.svelte +++ b/src/routes/characters/+page.svelte @@ -3,12 +3,17 @@ import { browser } from "$app/environment"; import { replaceState } from "$app/navigation"; import { page } from "$app/state"; - import { charactersOwned, animationsEnabled } from "$lib/stores"; + import { + charactersOwned, + charactersHydrated, + animationsEnabled, + } from "$lib/stores"; import BrowseFlipCard from "$lib/ui/components/BrowseFlipCard.svelte"; import CharacterFilterBar from "$lib/ui/components/CharacterFilterBar.svelte"; import PageShell from "$lib/ui/components/PageShell.svelte"; import PageTrail from "$lib/ui/components/PageTrail.svelte"; import EmptyState from "$lib/ui/components/EmptyState.svelte"; + import LoadingState from "$lib/ui/components/LoadingState.svelte"; import { CHARACTER_ELEMENTS, CHARACTER_WEAPON_TYPES, @@ -142,31 +147,35 @@

    View character build guides

    - - -

    {visible.length} shown

    - - {#if visible.length === 0} - + {#if !$charactersHydrated} + {:else} -
    - {#each visible as char (char.name_id)} - - {/each} -
    + + +

    {visible.length} shown

    + + {#if visible.length === 0} + + {:else} +
    + {#each visible as char (char.name_id)} + + {/each} +
    + {/if} {/if} diff --git a/src/routes/characters/[slug]/+page.svelte b/src/routes/characters/[slug]/+page.svelte index 6ff68b9..c1be7aa 100644 --- a/src/routes/characters/[slug]/+page.svelte +++ b/src/routes/characters/[slug]/+page.svelte @@ -1,2366 +1,2373 @@ - - -{#snippet descriptionBlock( - base: string, - enhance: { mode: "extra" | "replace"; text: string } | null, -)} - {#if enhance?.mode === "replace"} - - {:else} - - {#if enhance} - - {/if} - {/if} -{/snippet} - -{#snippet guideLinkList(links: CrimsonWitchLink[])} -
    -{/snippet} - -{#snippet talentRow(row: { - name: string; - icon: string | null; - rank?: number; - priority?: UpgradeTier; - kind?: "talent" | "level"; - priorityLabel?: string; - mean?: number; - median?: number; - min?: number; - max?: number; - teams?: number; -})} -
  • - {#if row.rank != null} - {row.rank} - {/if} - {#if row.icon} - - {/if} -
    -
    {row.name}
    - {#if row.kind != null && row.priority != null && row.priorityLabel != null} - - {/if} -
    -
  • -{/snippet} - -{#snippet consRow(row: { - cons: number; - priority: UpgradeTier; - priorityLabel: string; - mean?: number; - median?: number; - min?: number; - max?: number; - teams?: number; -})} - {@const constellation = kit.constellations.find((c) => c.index === row.cons)} - {@const icon = constellation - ? (iconUrl(constellation.icon, "talent") ?? - getUiAssetUrl(constellation.icon)) - : null} -
  • - C{row.cons} - {#if icon} - - {/if} -
    -
    - {constellation?.name ?? `C${row.cons}`} -
    - -
    -
  • -{/snippet} - -{#snippet sigRow(row: { - key: string; - priority: UpgradeTier; - priorityLabel: string; - mean?: number; - median?: number; - min?: number; - max?: number; - teams?: number; -})} -
  • - - - -
    -
    - -
    - -
    -
  • -{/snippet} - - -
    -
    -
    -
    -
    - {#if character} -
    - -
    - {/if} -
    -
    - -

    {kit.name}

    -
    -

    - {kit.title || "Character"} -

    -
    -
    -
    - -
    - - -
    - {#each TAB_OPTIONS as option, index (option.value)} - - {/each} -
    - -
    - {#if activeTab === "skills"} -
    - {#if kit.is_traveler && travelerSkillOptions.length > 0} -
    -
    -
    - Element: - -
    - {#if teamsMode === "simulated"} - {CHARACTER_SIM_COST} - {/if} -
    - - {#if teamsMode === "simulated"} - {#if teamsLoading} - - {:else if investmentError && simulatedTeams.length === 0} - - {#snippet action()} - - {/snippet} - - {:else if simulatedTeams.length === 0} - - {:else} -
      - {#each simulatedTeams as row, i (row.team.team_key)} -
    1. - - -
    2. - {/each} -
    - {/if} - {:else if teamsLoading} - - {:else if $staticBoardsError && popularTeams.length === 0} - - {#snippet action()} - - {/snippet} - - {:else if popularTeams.length === 0} - - {:else} -
      - {#each popularTeams as team, i (team.team_key ?? i)} -
    1. - - -
    2. - {/each} -
    - {/if} -
    -
    - {:else if activeTab === "analytics"} -
    -
    -
    -
    - Usage: - effectiveElement, (next) => (skillsElement = next) + } + bare + aria-labelledby="skills-element-label skills-element-trigger" + /> +
    +
    +
    + {/if} +
    +

    Talents

    +
    + {#each skillsKit.skills as skill (skill.id)} + {@const icon = + iconUrl(skill.icon, "skill") ?? getUiAssetUrl(skill.icon)} + {@const skillEnhance = enhanceExtra( + skill.description, + skill.enhanceDescription, + )} +
    + {#if icon} + + {/if} +
    +
    +

    {skill.name}

    + + {SKILL_LABELS[skill.type] ?? skill.type} + +
    + {@render descriptionBlock( + skill.description, + skillEnhance, + )} +
    +
    + {/each} +
    +
    + +
    +

    Passives

    +
    + {#each skillsKit.passives as passive (passive.id)} + {@const icon = + iconUrl(passive.icon, "talent") ?? + getUiAssetUrl(passive.icon)} + {@const passiveEnhance = enhanceExtra( + passive.description, + passive.enhanceDescription, + )} +
    + {#if icon} + + {/if} +
    +
    +

    {passive.name}

    + {passiveKindLabel(passive)} +
    + {@render descriptionBlock( + passive.description, + passiveEnhance, + )} +
    +
    + {/each} +
    +
    + +
    +

    Constellations

    +
    + {#each skillsKit.constellations as c (c.id)} + {@const icon = + iconUrl(c.icon, "talent") ?? getUiAssetUrl(c.icon)} + {@const constEnhance = enhanceExtra( + c.description, + c.enhanceDescription, + )} +
    + {#if icon} + + {/if} +
    +
    + + C{c.index} + +

    {c.name}

    +
    + {@render descriptionBlock(c.description, constEnhance)} +
    +
    + {/each} +
    +
    +
    + {:else if activeTab === "teams"} +
    +
    +
    +
    + Teams: + +
    +
    + + {#if analyticsError && !analyticsPayload} + + {#snippet action()} + + {/snippet} + + {:else if analyticsPayload && analyticsKey === `${analyticsMode}:${kit.name_id}`} + {#if analyticsPayload.usage.length === 0} + + {:else} +
    + +
    + {#if analyticsTeamsByVersion.length > 0} +
    +

    Top teams by version

    + {#each analyticsTeamsByVersion as group (group.version_number)} +
    +

    {group.version_name}

    +
      + {#each group.teams as team, i (team.team_key ?? `${group.version_number}-${i}`)} +
    1. + + +
    2. + {/each} +
    +
    + {/each} +
    + {/if} + {/if} + {:else} + + {/if} +
    +
    + {:else if activeTab === "links"} + + {:else} +
    + {#if builds} +
    +

    Weapons

    + {#if rankedWeapons.length === 0} +

    No weapon data yet.

    + {:else} +
    + {#each rankedWeapons as w (w.key)} +
    +
    + +
    + +
    + {/each} +
    + {/if} +
    + +
    +

    Artifact sets

    + {#if !builds.sets?.length} +

    No set data yet.

    + {:else} + {#key $equipmentVersion} +
    + {#each builds.sets as s} + {@const set = artifactSetByKey.get(s.key)} + {@const icon = set ? artifactIconUrl(set.icon) : null} +
    +
    + {#if icon} + {set?.name + {:else} +
    {s.key}
    + {/if} + {#if s.count} +
    + {s.count}pc +
    + {/if} +
    + +
    + {/each} +
    + {/key} + {/if} +
    + +
    +

    Main stats

    +
    + {#each MAIN_STAT_SLOTS as slot} +
    +

    + + {slot.label} +

    + {#if builds.main_stats[slot.key].length === 0} +

    + {:else} +
      + {#each builds.main_stats[slot.key] as stat} + {@const icon = statIconUrl(stat.key)} +
    • + + {#if icon} + + {/if} + {translateStatKey(stat.key)} + +
    • + {/each} +
    + {/if} +
    + {/each} +
    +
    + +
    +

    Recommended substats

    + {#if recommendedSubstats.length === 0} +

    No substat data yet.

    + {:else} +
    + {#each recommendedSubstats as roll} + {@const icon = statIconUrl(roll.key)} + + {/each} +
    + {/if} +
    + + {#if talentSection || levelSection || consSection || sigSection} +
    +
    + {#if talentSection} +
    +

    + Talent priority + {#if talentSection.source === "guide" && talentSection.simMissing} + (no simulation data yet) + {/if} +

    +
      + {#if talentSection.source === "sim"} + {#each talentSection.rows as row, i} + {@render talentRow({ + name: row.label, + icon: row.icon, + rank: i + 1, + priority: row.priority, + kind: "talent", + priorityLabel: row.priorityLabel, + mean: row.mean, + median: row.median, + min: row.min, + max: row.max, + teams: row.teams, + })} + {/each} + {:else} + {#each talentSection.rows as row, i} + {@render talentRow({ + name: row.label, + icon: row.icon, + rank: i + 1, + })} + {/each} + {/if} +
    +
    + {/if} + + {#if levelSection} +
    +

    + Character level + {#if levelSection.source === "guide" && levelSection.simMissing} + (no simulation data yet) + {/if} +

    +
      + {#if levelSection.source === "sim"} + {@render talentRow({ + name: "Level 90", + icon: levelIcon, + priority: levelSection.row.priority, + kind: "level", + priorityLabel: levelSection.row.priorityLabel, + mean: levelSection.row.mean, + median: levelSection.row.median, + min: levelSection.row.min, + max: levelSection.row.max, + teams: levelSection.row.teams, + })} + {:else} + {@render talentRow({ + name: "Level 90", + icon: levelIcon, + priority: levelSection.priority, + kind: "level", + priorityLabel: levelSection.priorityLabel, + })} + {/if} +
    +
    + {/if} +
    + +
    + {#if consSection} +
    +

    + Constellation Impact + {#if consSection.source === "guide" && consSection.simMissing} + (no simulation data yet) + {/if} +

    +
      + {#if consSection.source === "sim"} + {#each consSection.rows as row} + {@render consRow({ + cons: row.cons, + priority: row.priority, + priorityLabel: row.priorityLabel, + mean: row.mean_pct_gain, + median: row.median_pct_gain, + min: row.min_pct_gain, + max: row.max_pct_gain, + teams: row.teams, + })} + {/each} + {:else} + {#each consSection.rows as row} + {@render consRow(row)} + {/each} + {/if} +
    +
    + {/if} + + {#if sigSection} +
    +

    + Signature weapon impact + {#if sigSection.source === "guide" && sigSection.simMissing} + (no simulation data yet) + {/if} +

    +
      + {#if sigSection.source === "sim"} + {#each sigSection.rows as row} + {@render sigRow({ + key: row.key, + priority: row.priority, + priorityLabel: row.priorityLabel, + mean: row.mean_pct_gain, + median: row.median_pct_gain, + min: row.min_pct_gain, + max: row.max_pct_gain, + teams: row.teams, + })} + {/each} + {:else} + {#each sigSection.rows as row} + {@render sigRow(row)} + {/each} + {/if} +
    +
    + {/if} +
    +
    + {/if} + + {#if builds.notes} +
    +

    Notes

    +

    {builds.notes}

    +
    + {/if} + {:else} +
    +

    + No Lightkeepers build summary for {kit.name} yet. +

    + {#if crimsonWitchLinks.length === 1 && crimsonWitchLinks[0]} + + + Crimson Witch build guide + + {:else if crimsonWitchLinks.length > 1} + {@render guideLinkList(crimsonWitchLinks)} + {/if} +
    + {/if} +
    + {/if} +
    +
    +
    +
    + + diff --git a/src/routes/dev/ui/+page.svelte b/src/routes/dev/ui/+page.svelte index 59d7277..3d921aa 100644 --- a/src/routes/dev/ui/+page.svelte +++ b/src/routes/dev/ui/+page.svelte @@ -1,3109 +1,3122 @@ - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + diff --git a/src/routes/patch-notes/+page.server.ts b/src/routes/patch-notes/+page.server.ts new file mode 100644 index 0000000..6132e1e --- /dev/null +++ b/src/routes/patch-notes/+page.server.ts @@ -0,0 +1,19 @@ +import type { PageServerLoad } from "./$types"; +import { listPatchNotes } from "$lib/patch-notes-catalog"; + +export const load: PageServerLoad = async () => { + const notes = listPatchNotes().map(({ slug, title, date, summary }) => ({ + slug, + title, + date, + summary, + })); + return { + notes, + seo: { + title: "Patch notes · Lightkeepers", + description: + "Product updates for Lightkeepers — roster sync, team tools, and site changes.", + }, + }; +}; diff --git a/src/routes/patch-notes/+page.svelte b/src/routes/patch-notes/+page.svelte new file mode 100644 index 0000000..0207fef --- /dev/null +++ b/src/routes/patch-notes/+page.svelte @@ -0,0 +1,105 @@ + + + +
    +
    +

    Patch notes

    +

    + Product updates — same source as GitHub Releases and Discord. +

    +
    +
    + + {#if data.notes.length === 0} +

    No notes yet.

    + {:else} + + {/if} +
    + + diff --git a/src/routes/patch-notes/[slug]/+page.server.ts b/src/routes/patch-notes/[slug]/+page.server.ts new file mode 100644 index 0000000..9e5c18f --- /dev/null +++ b/src/routes/patch-notes/[slug]/+page.server.ts @@ -0,0 +1,24 @@ +import { error } from "@sveltejs/kit"; +import type { PageServerLoad } from "./$types"; +import { getPatchNote } from "$lib/patch-notes-catalog"; +import { renderPatchNoteBody } from "$lib/patch-notes"; + +export const load: PageServerLoad = async ({ params }) => { + const note = getPatchNote(params.slug); + if (!note) { + error(404, "Patch note not found"); + } + return { + note: { + slug: note.slug, + title: note.title, + date: note.date, + summary: note.summary, + html: renderPatchNoteBody(note.body), + }, + seo: { + title: `${note.title} · Patch notes · Lightkeepers`, + description: note.summary, + }, + }; +}; diff --git a/src/routes/patch-notes/[slug]/+page.svelte b/src/routes/patch-notes/[slug]/+page.svelte new file mode 100644 index 0000000..8ba48cb --- /dev/null +++ b/src/routes/patch-notes/[slug]/+page.svelte @@ -0,0 +1,110 @@ + + + +
    + +
    +

    {data.note.title}

    +

    + +

    +
    +
    + +
    + {@html data.note.html} +
    +
    + + diff --git a/src/routes/settings/panels/RosterPanel.svelte b/src/routes/settings/panels/RosterPanel.svelte index 1b1c270..3dc2703 100644 --- a/src/routes/settings/panels/RosterPanel.svelte +++ b/src/routes/settings/panels/RosterPanel.svelte @@ -112,7 +112,9 @@ const result = await postRoster(pending.roster); if (!result.ok) { restoreSavedSnapshot(); - rosterError = `Sync failed (${result.status}) — roster not saved to cloud`; + rosterError = result.message + ? `Sync failed (${result.status}): ${result.message}` + : `Sync failed (${result.status}) — roster not saved to cloud`; return; } commitSaved(pending); @@ -285,7 +287,7 @@ {/if}
    {:else} - + {/if}